Compare commits

..

3 Commits

Author SHA1 Message Date
Alex e93f0db224 Merge branch 'main' into apk-build-deployment 2026-06-12 14:10:24 -05:00
Alex Tran 46d8be8ffc different app name for staging 2026-06-05 16:16:03 -05:00
Alex Tran df051c24b3 chore: apk build deployment 2026-06-05 15:31:20 -05:00
8 changed files with 75 additions and 156 deletions
+54
View File
@@ -73,6 +73,7 @@ jobs:
needs: pre-job
permissions:
contents: read
deployments: write
pull-requests: write
if: ${{ github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich
@@ -142,9 +143,18 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
IS_MAIN_DEPLOYMENT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
IS_DEPLOYMENT_BUILD: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
if [[ $IS_DEPLOYMENT_BUILD == 'true' ]]; then
export ANDROID_APP_LABEL='Immich Staging'
fi
if [[ $IS_MAIN == 'true' ]]; then
if [[ $IS_MAIN_DEPLOYMENT == 'true' ]]; then
export ANDROID_APPLICATION_ID=app.immich.main
fi
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
@@ -158,6 +168,50 @@ jobs:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Publish Android APK deployment
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with:
script: |
const artifactUrl = process.env.APK_URL;
const isPullRequest = context.eventName === "pull_request";
const pullNumber = context.payload.pull_request?.number;
const environment = isPullRequest ? `mobile-android-apk-pr-${pullNumber}` : "mobile-android-apk";
const description = isPullRequest
? `Signed Android APK for PR #${pullNumber}`
: "Latest signed Android APK from main";
const ref = isPullRequest ? context.payload.pull_request.head.sha : context.sha;
if (!artifactUrl) {
throw new Error("The Android APK artifact URL was not generated");
}
const runUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
environment,
description,
auto_merge: false,
required_contexts: [],
production_environment: false,
transient_environment: isPullRequest,
});
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.data.id,
state: "success",
environment,
environment_url: artifactUrl,
log_url: runUrl,
description: "Signed APK artifact is ready for testing",
});
- name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
+12 -1
View File
@@ -13,6 +13,16 @@ if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
}
def androidApplicationId = System.getenv("ANDROID_APPLICATION_ID")?.trim()
if (!androidApplicationId) {
androidApplicationId = "app.alextran.immich"
}
def androidAppLabel = System.getenv("ANDROID_APP_LABEL")?.trim()
if (!androidAppLabel) {
androidAppLabel = "Immich"
}
android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -37,11 +47,12 @@ android {
}
defaultConfig {
applicationId "app.alextran.immich"
applicationId androidApplicationId
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode flutter.versionCode
versionName flutter.versionName
manifestPlaceholders = [appLabel: androidAppLabel]
}
signingConfigs {
@@ -25,7 +25,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
<application android:label="${appLabel}" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config">
+2 -8
View File
@@ -70,10 +70,7 @@ class DeepLinkService {
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
// /albums/<albumId>/photos/<assetId> links carry the album context,
// which drives the like/comment UI in the viewer
final albumId = albumRegex.firstMatch(path)?.group(1);
return _buildAssetDeepLink(assetId, ref, albumId: albumId);
return _buildAssetDeepLink(assetId, ref);
}
if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
@@ -110,19 +107,16 @@ class DeepLinkService {
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
}
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async {
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
final asset = await _betaAssetService.getRemoteAsset(assetId);
if (asset == null) {
return null;
}
final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null;
AssetViewer.setAsset(ref, asset);
return AssetViewerRoute(
initialIndex: 0,
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
currentAlbum: album,
);
}
@@ -1,140 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:mocktail/mocktail.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockAssetService extends Mock implements AssetService {}
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockDriftMemoryService extends Mock implements DriftMemoryService {}
class MockDriftPeopleService extends Mock implements DriftPeopleService {}
class MockPlatformDeepLink extends Mock implements PlatformDeepLink {}
class MockWidgetRef extends Mock implements WidgetRef {}
class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {}
const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd';
final _asset = RemoteAsset(
id: _assetId,
name: 'photo.jpg',
ownerId: 'user-1',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isEdited: false,
);
final _album = RemoteAlbum(
id: _albumId,
name: 'Shared Album',
ownerId: 'user-1',
description: '',
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isActivityEnabled: true,
isShared: true,
order: AlbumAssetOrder.asc,
assetCount: 1,
ownerName: 'Owner',
);
void main() {
late MockTimelineFactory timelineFactory;
late MockAssetService assetService;
late MockRemoteAlbumService remoteAlbumService;
late MockWidgetRef ref;
late List<TimelineService> createdTimelineServices;
late DeepLinkService sut;
setUp(() {
timelineFactory = MockTimelineFactory();
assetService = MockAssetService();
remoteAlbumService = MockRemoteAlbumService();
ref = MockWidgetRef();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: TimelineOrigin.deepLink,
));
createdTimelineServices.add(timelineService);
return timelineService;
});
when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier());
sut = DeepLinkService(
timelineFactory,
assetService,
remoteAlbumService,
MockDriftMemoryService(),
MockDriftPeopleService(),
null,
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
});
});
PlatformDeepLink link(String path) {
final deepLink = MockPlatformDeepLink();
when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path'));
return deepLink;
}
test('album photo link carries the album into the viewer route', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album);
});
test('still opens the viewer when the album cannot be resolved', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
});
test('plain photo link has no album', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
verifyNever(() => remoteAlbumService.get(any()));
});
}
+4 -4
View File
@@ -261,6 +261,10 @@
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
@@ -286,10 +290,6 @@
{/if}
</div>
{#if show.brokenAsset}
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
{/if}
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
+1 -1
View File
@@ -69,7 +69,7 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export const maximumLengthSearchPeople = 100;
export const maximumLengthSearchPeople = 1000;
// time to load the map before displaying the loading spinner
export const timeToLoadTheMap: number = 100;
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
#isImageLoading = $derived.by(() => {
const quality = this.imageLoaderStatus?.quality;
if (!quality || this.imageLoaderStatus?.hasError) {
if (!quality) {
return false;
}
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';