Compare commits

..

2 Commits

Author SHA1 Message Date
Adam Gastineau
5302d6ea0e fix(mobile): allow URL validation to pass when scheme is not provided 2026-07-22 12:39:23 -07:00
Pavel Miniutka
564cda5088 chore(mobile): Adds Belarusian language option in settings on mobile (#29939)
chore(mobile): add missing Belarusian (be) language option in settings
2026-07-22 17:53:21 +00:00
13 changed files with 70 additions and 360 deletions

View File

@@ -6,6 +6,7 @@ const Map<String, Locale> locales = {
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),

View File

@@ -24,19 +24,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
const DriftLocalAssetRepository(this._db) : super(_db);
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
final query =
_db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum) &
_db.remoteAssetEntity.ownerId.isInQuery(
_db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id]),
),
useColumns: false,
),
])
..where(_db.localAssetEntity.id.equals(id))
..limit(1);
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(_db.localAssetEntity.id.equals(id));
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();

View File

@@ -59,13 +59,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future<RemoteAsset?> getByChecksum(String checksum) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.checksum.equals(checksum) &
row.ownerId.isInQuery(_db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id])),
)
..limit(1);
final query = _db.remoteAssetEntity.select()..where((row) => row.checksum.equals(checksum));
return query.map((row) => row.toDto()).getSingleOrNull();
}

View File

@@ -136,6 +136,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..addColumns([assetCountExp, dateExp])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
@@ -159,10 +164,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum) &
_db.remoteAssetEntity.ownerId.isInQuery(
_db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id]),
),
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])

View File

@@ -16,11 +16,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
/// Matches remote_asset_entity rows owned by the current user. The asset is unique over (owner, checksum),
/// so partners can have a duplicate checksum
Expression<bool> get _ownedByCurrentUser =>
_db.remoteAssetEntity.ownerId.isInQuery(_db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id]));
Future<void> updateHashes(Map<String, String> hashes) {
if (hashes.isEmpty) {
return Future.value();
@@ -50,7 +45,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
await (_db.select(_db.trashedLocalAssetEntity).join([
innerJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum) & _ownedByCurrentUser,
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
),
])..where(
_db.trashedLocalAssetEntity.source.equalsValue(TrashOrigin.remoteSync) &
@@ -278,7 +273,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum) & _ownedByCurrentUser,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &

View File

@@ -10,6 +10,24 @@ String sanitizeUrl(String url) {
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
}
/// Validates a user-entered server URL
bool isValidServerUrl(String? url) {
if (url == null || url.isEmpty) {
return true;
}
if (!url.contains('://')) {
// Prepend conforming scheme for URL validation
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
url = 'http://$url';
}
final parsedUrl = Uri.tryParse(url);
return parsedUrl != null &&
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
parsedUrl.host.isNotEmpty;
}
String? getServerUrl() {
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;

View File

@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) {
return null;
}
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
String? _validateEmail(String? email) {
if (email == null || email == '') {

View File

@@ -19,72 +19,6 @@ void main() {
await ctx.dispose();
});
group('get', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
// Owner-scoped queries resolve the current user via authUserEntity.
await ctx.newAuthUser(id: userId);
});
test('allows the same checksum to exist for multiple owners (#29973)', () async {
const checksum = 'some-shared-checksum';
final mine = await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum);
final local = await ctx.newLocalAsset(checksum: checksum);
final result = await sut.get(local.id);
expect(result, isNotNull);
expect(result!.id, local.id);
// We must explicitly get OUR asset, not the partner's
expect(result.remoteId, mine.id);
});
test('reports local-only when only a partner has a remote copy (#29973)', () async {
// The current user has NOT uploaded this file; only a partner owns an identical-checksum remote asset
const checksum = 'partner-only';
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum);
final local = await ctx.newLocalAsset(checksum: checksum);
final result = await sut.get(local.id);
expect(result, isNotNull);
expect(result!.remoteId, isNull);
expect(result.storage, AssetState.local);
});
test('allows the current user to have multiple remote rows for one checksum (#29973)', () async {
// A single user can have many remote assets with the same checksum (their upload + external library copy)
const checksum = 'multi-library';
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final local = await ctx.newLocalAsset(checksum: checksum);
final result = await sut.get(local.id);
expect(result, isNotNull);
expect(result!.id, local.id);
expect(result.remoteId, isNotNull);
});
test('attaches remoteId to local asset automatically in simple scenarios', () async {
const checksum = 'simple';
final remote = await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final local = await ctx.newLocalAsset(checksum: checksum);
final result = await sut.get(local.id);
expect(result, isNotNull);
expect(result!.remoteId, remote.id);
expect(result.storage, AssetState.merged);
});
});
group('getRemovalCandidates', () {
final cutoffDate = DateTime(2024, 1, 1);
final beforeCutoff = DateTime(2023, 12, 31);

View File

@@ -1,61 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late RemoteAssetRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = RemoteAssetRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getByChecksum', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
});
test('returns the current user\'s asset when a partner shares the checksum', () async {
const checksum = 'shared-partner-checksum';
final mine = await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum);
final result = await sut.getByChecksum(checksum);
expect(result, isNotNull);
expect(result!.id, mine.id);
expect(result.ownerId, userId);
});
test('returns null when current user does not own the checksum, but a partner does', () async {
const checksum = 'partner-only';
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum);
final result = await sut.getByChecksum(checksum);
expect(result, isNull);
});
test('returns the current user\'s asset', () async {
const checksum = 'simple';
final remote = await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final result = await sut.getByChecksum(checksum);
expect(result, isNotNull);
expect(result!.id, remote.id);
});
});
}

View File

@@ -102,48 +102,4 @@ void main() {
expect(remote.localId, local.id);
});
});
group('localAlbum assets', () {
late String userId;
late String otherUserId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
final other = await ctx.newUser();
otherUserId = other.id;
});
test('does not duplicate assets when a partner shares the checksum', () async {
const checksum = 'shared-partner-checksum';
final album = await ctx.newLocalAlbum();
final local = await ctx.newLocalAsset(checksum: checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final myRemote = await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
await ctx.newRemoteAsset(ownerId: otherUserId, checksum: checksum);
final assets = await sut.localAlbum(album.id, .day).assetSource(0, 10);
expect(assets, hasLength(1));
final asset = assets.single as LocalAsset;
expect(asset.id, local.id);
// Must resolve the current user's remote id
expect(asset.remoteId, myRemote.id);
});
test('bucket count ignores a partner sharing the checksum', () async {
const checksum = 'shared-partner-checksum';
final album = await ctx.newLocalAlbum();
final local = await ctx.newLocalAsset(checksum: checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
await ctx.newRemoteAsset(ownerId: otherUserId, checksum: checksum);
final buckets = await sut.localAlbum(album.id, .day).bucketSource().first;
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
});
});
}

View File

@@ -1,100 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftTrashedLocalAssetRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftTrashedLocalAssetRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getToRestore', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
});
test('does not restore based on a partner\'s live remote copy', () async {
const checksum = 'shared-partner-checksum';
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final trashed = await ctx.newTrashedLocalAsset(albumId: album.id, checksum: checksum);
// Current user's own remote copy is deleted; only a partner has an active copy.
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum, deletedAt: DateTime(2020, 1, 1));
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum);
final result = await sut.getToRestore();
final ids = result.map((a) => a.id);
expect(ids, isNot(contains(trashed.id)));
});
test('restores when the current user\'s own remote copy is live', () async {
const checksum = 'my-live-copy';
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final trashed = await ctx.newTrashedLocalAsset(albumId: album.id, checksum: checksum);
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final result = await sut.getToRestore();
final ids = result.map((a) => a.id);
expect(ids, contains(trashed.id));
});
});
group('getToTrash', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
});
Future<String> addLocalAssetToBackupAlbum(String checksum) async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final local = await ctx.newLocalAsset(checksum: checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
return local.id;
}
test('does not trash when only a partner\'s remote copy is deleted', () async {
const checksum = 'shared-partner-checksum';
final localId = await addLocalAssetToBackupAlbum(checksum);
// Current user's own remote copy is live but a partner deleted theirs
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum);
final partner = await ctx.newUser();
await ctx.newRemoteAsset(ownerId: partner.id, checksum: checksum, deletedAt: DateTime(2020, 1, 1));
final result = await sut.getToTrash();
final ids = result.values.expand((assets) => assets).map((a) => a.id);
expect(ids, isNot(contains(localId)));
});
test('trashes when the current user\'s own remote copy is deleted', () async {
const checksum = 'my-deleted-copy';
final localId = await addLocalAssetToBackupAlbum(checksum);
await ctx.newRemoteAsset(ownerId: userId, checksum: checksum, deletedAt: DateTime(2020, 1, 1));
final result = await sut.getToTrash();
final ids = result.values.expand((assets) => assets).map((a) => a.id);
expect(ids, contains(localId));
});
});
}

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -19,8 +18,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
@@ -75,22 +72,6 @@ class MediumRepositoryContext {
);
}
/// Seeds a user into `authUserEntity` as the currently authenticated user
Future<AuthUserEntityData> newAuthUser({String? id, String? email, bool? isAdmin, AvatarColor? avatarColor}) async {
id ??= TestUtils.uuid();
return db
.into(db.authUserEntity)
.insertReturning(
AuthUserEntityCompanion(
id: .new(id),
email: .new(email ?? '$id@test.com'),
name: .new('user_$id'),
isAdmin: .new(isAdmin ?? false),
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
),
);
}
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
return db
.into(db.partnerEntity)
@@ -305,33 +286,6 @@ class MediumRepositoryContext {
);
}
/// Seeds a trashed local asset into `trashedLocalAssetEntity`
Future<TrashedLocalAssetEntityData> newTrashedLocalAsset({
String? id,
required String albumId,
String? checksum,
TrashOrigin? source,
AssetType? type,
DateTime? createdAt,
bool? isFavorite,
}) async {
id ??= TestUtils.uuid();
return db
.into(db.trashedLocalAssetEntity)
.insertReturning(
TrashedLocalAssetEntityCompanion(
id: .new(id),
albumId: .new(albumId),
name: .new('trashed_$id.jpg'),
type: .new(type ?? .image),
checksum: .new(checksum),
source: .new(source ?? TrashOrigin.remoteSync),
isFavorite: .new(isFavorite ?? false),
createdAt: .new(TestUtils.date(createdAt)),
),
);
}
Future<LocalAlbumEntityData> newLocalAlbum({
String? id,
String? name,

View File

@@ -77,6 +77,40 @@ void main() {
});
});
group('isValidServerUrl', () {
test('should treat null as valid', () {
expect(isValidServerUrl(null), isTrue);
});
test('should treat empty string as valid', () {
expect(isValidServerUrl(''), isTrue);
});
test('should accept a bare host', () {
expect(isValidServerUrl('demo.immich.app'), isTrue);
});
test('should accept a bare host with a port', () {
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
});
test('should accept an http URL', () {
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
});
test('should accept an https URL', () {
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
});
test('should reject a non-http scheme', () {
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
});
test('should reject scheme only input', () {
expect(isValidServerUrl('https://'), isFalse);
});
});
group('punycodeDecodeUrl', () {
test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull);