Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(mobile): boiler plate for new stream sync mechanism #13699

Draft
wants to merge 18 commits into
base: feat/mobile-sync-endpoints
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions e2e/src/api/specs/sync.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';

describe('/sync', () => {
let admin: LoginResponseDto;

beforeAll(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});

describe('GET /sync/acknowledge', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/sync/acknowledge');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});

it('should work', async () => {
const { status } = await request(app)
.post('/sync/acknowledge')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({});
expect(status).toBe(204);
});

it('should work with an album sync date', async () => {
const { status } = await request(app)
.post('/sync/acknowledge')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
album: {
id: uuidDto.dummy,
timestamp: '2024-10-23T21:01:07.732Z',
},
});
expect(status).toBe(204);
});
});

describe('GET /sync/stream', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/sync/stream');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});

it('should require at least type', async () => {
const { status, body } = await request(app)
.post('/sync/stream')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ types: [] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});

it('should require valid types', async () => {
const { status, body } = await request(app)
.post('/sync/stream')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
);
});

it('should accept a valid type', async () => {
const response = await request(app)
.post('/sync/stream')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ types: ['asset'] });
expect(response.status).toBe(200);
expect(response.get('Content-Type')).toBe('application/jsonlines+json; charset=utf-8');
expect(response.body).toEqual('');
});
});
});
1 change: 1 addition & 0 deletions e2e/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000000',
};

const adminLoginDto = {
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/interfaces/sync.interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:openapi/api.dart';

abstract interface class ISyncRepository implements IDatabaseRepository {}
12 changes: 12 additions & 0 deletions mobile/lib/interfaces/sync_api.interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:immich_mobile/models/sync/sync_event.model.dart';
import 'package:openapi/api.dart';

abstract interface class ISyncApiRepository {
Stream<List<SyncEvent>> getChanges(SyncStreamDtoTypesEnum type);

Future<void> confirmChanges(
SyncStreamDtoTypesEnum type,
String id,
String timestamp,
);
}
94 changes: 94 additions & 0 deletions mobile/lib/models/sync/sync_event.model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first

import 'dart:convert';

import 'package:openapi/api.dart';

class SyncEvent {
// enum
final SyncStreamDtoTypesEnum type;

// enum
final SyncAction action;

// dynamic
final dynamic data;

// Acknowledge info
final String id;
final String timestamp;

SyncEvent({
required this.type,
required this.action,
required this.data,
required this.id,
required this.timestamp,
});

SyncEvent copyWith({
SyncStreamDtoTypesEnum? type,
SyncAction? action,
dynamic data,
String? id,
String? timestamp,
}) {
return SyncEvent(
type: type ?? this.type,
action: action ?? this.action,
data: data ?? this.data,
id: id ?? this.id,
timestamp: timestamp ?? this.timestamp,
);
}

Map<String, dynamic> toMap() {
return <String, dynamic>{
'type': type,
'action': action,
'data': data,
'id': id,
'timestamp': timestamp,
};
}

factory SyncEvent.fromMap(Map<String, dynamic> map) {
return SyncEvent(
type: SyncStreamDtoTypesEnum.values[map['type'] as int],
action: SyncAction.values[map['action'] as int],
data: map['data'] as dynamic,
id: map['id'] as String,
timestamp: map['timestamp'] as String,
);
}

String toJson() => json.encode(toMap());

factory SyncEvent.fromJson(String source) =>
SyncEvent.fromMap(json.decode(source) as Map<String, dynamic>);

@override
String toString() {
return 'SyncEvent(type: $type, action: $action, data: $data, id: $id, timestamp: $timestamp)';
}

@override
bool operator ==(covariant SyncEvent other) {
if (identical(this, other)) return true;

return other.type == type &&
other.action == action &&
other.data == data &&
other.id == id &&
other.timestamp == timestamp;
}

@override
int get hashCode {
return type.hashCode ^
action.hashCode ^
data.hashCode ^
id.hashCode ^
timestamp.hashCode;
}
}
11 changes: 9 additions & 2 deletions mobile/lib/pages/search/search.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
Expand Down Expand Up @@ -674,11 +675,11 @@ class SearchEmptyContent extends StatelessWidget {
}
}

class QuickLinkList extends StatelessWidget {
class QuickLinkList extends ConsumerWidget {
const QuickLinkList({super.key});

@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
Expand Down Expand Up @@ -717,6 +718,12 @@ class QuickLinkList extends StatelessWidget {
isBottom: true,
onTap: () => context.pushRoute(FavoritesRoute()),
),
QuickLink(
title: 'test'.tr(),
icon: Icons.favorite_border_rounded,
isBottom: true,
onTap: () => ref.read(syncServiceProvider).syncAssets(),
),
],
),
);
Expand Down
14 changes: 14 additions & 0 deletions mobile/lib/repositories/sync.repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:immich_mobile/interfaces/sync.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

final syncRepositoryProvider = Provider(
(ref) => SyncRepository(
ref.watch(dbProvider),
),
);

class SyncRepository extends DatabaseRepository implements ISyncRepository {
SyncRepository(super.db);
}
Loading
Loading