Skip to content
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
140 changes: 140 additions & 0 deletions mobile/apps/photos/lib/db/memory_shares_db.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'dart:io';

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photos/models/api/memory_share/memory_share.dart';
import 'package:sqflite/sqflite.dart';

class MemorySharesDB {
static const _databaseName = "ente.memory_shares.db";
static const _databaseVersion = 1;

static const _table = 'memory_shares';

static const _columnID = 'id';
static const _columnType = 'type';
static const _columnMetadataCipher = 'metadata_cipher';
static const _columnMetadataNonce = 'metadata_nonce';
static const _columnMemEncKey = 'mem_enc_key';
static const _columnMemKeyDecryptionNonce = 'mem_key_decryption_nonce';
static const _columnAccessToken = 'access_token';
static const _columnIsDeleted = 'is_deleted';
static const _columnCreatedAt = 'created_at';
static const _columnUpdatedAt = 'updated_at';
static const _columnUrl = 'url';

MemorySharesDB._();
static final MemorySharesDB instance = MemorySharesDB._();

static Future<Database>? _dbFuture;

Future<Database> get database async {
_dbFuture ??= _initDatabase();
return _dbFuture!;
}

Future<Database> _initDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
);
}

Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $_table (
$_columnID INTEGER PRIMARY KEY NOT NULL,
$_columnType TEXT NOT NULL,
$_columnMetadataCipher TEXT,
$_columnMetadataNonce TEXT,
$_columnMemEncKey TEXT NOT NULL,
$_columnMemKeyDecryptionNonce TEXT NOT NULL,
$_columnAccessToken TEXT NOT NULL,
$_columnIsDeleted INTEGER NOT NULL DEFAULT 0,
$_columnCreatedAt INTEGER NOT NULL,
$_columnUpdatedAt INTEGER,
$_columnUrl TEXT NOT NULL
)
''');
}

Future<void> upsert(MemoryShare share) async {
final db = await database;
await db.insert(
_table,
_toRow(share),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}

Future<List<MemoryShare>> getAll() async {
final db = await database;
final rows = await db.query(
_table,
where: '$_columnIsDeleted = 0',
orderBy: '$_columnCreatedAt DESC',
);
return rows.map(_fromRow).toList();
}

Future<MemoryShare?> getById(int id) async {
final db = await database;
final rows = await db.query(
_table,
where: '$_columnID = ?',
whereArgs: [id],
);
if (rows.isEmpty) return null;
return _fromRow(rows.first);
}

Future<void> delete(int id) async {
final db = await database;
await db.delete(
_table,
where: '$_columnID = ?',
whereArgs: [id],
);
}

Future<void> clearTable() async {
final db = await database;
await db.delete(_table);
}

Map<String, dynamic> _toRow(MemoryShare share) {
return {
_columnID: share.id,
_columnType: share.type.name,
_columnMetadataCipher: share.metadataCipher,
_columnMetadataNonce: share.metadataNonce,
_columnMemEncKey: share.encryptedKey,
_columnMemKeyDecryptionNonce: share.keyDecryptionNonce,
_columnAccessToken: share.accessToken,
_columnIsDeleted: share.isDeleted ? 1 : 0,
_columnCreatedAt: share.createdAt,
_columnUpdatedAt: share.updatedAt,
_columnUrl: share.url,
};
}

MemoryShare _fromRow(Map<String, dynamic> row) {
return MemoryShare(
id: row[_columnID] as int,
type: MemoryShareType.fromString(row[_columnType] as String),
metadataCipher: row[_columnMetadataCipher] as String?,
metadataNonce: row[_columnMetadataNonce] as String?,
encryptedKey: row[_columnMemEncKey] as String,
keyDecryptionNonce: row[_columnMemKeyDecryptionNonce] as String,
accessToken: row[_columnAccessToken] as String,
isDeleted: (row[_columnIsDeleted] as int) == 1,
createdAt: row[_columnCreatedAt] as int,
updatedAt: row[_columnUpdatedAt] as int?,
url: row[_columnUrl] as String,
);
}
}
5 changes: 4 additions & 1 deletion mobile/apps/photos/lib/models/api/entity/type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ enum EntityType {
person,
cgroup,
unknown,
smartAlbum;
smartAlbum,
memory;

bool get isZipped {
switch (this) {
Expand All @@ -25,6 +26,8 @@ enum EntityType {
return "cgroup";
case EntityType.smartAlbum:
return "smart_album";
case EntityType.memory:
return "memory";
case EntityType.unknown:
return "unknown";
}
Expand Down
93 changes: 93 additions & 0 deletions mobile/apps/photos/lib/models/api/memory_share/memory_share.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
enum MemoryShareType {
share,
lane;

static MemoryShareType fromString(String value) {
switch (value) {
case 'lane':
return MemoryShareType.lane;
default:
return MemoryShareType.share;
}
}
}

class MemoryShare {
final int id;
final MemoryShareType type;
final String? metadataCipher;
final String? metadataNonce;
final String encryptedKey;
final String keyDecryptionNonce;
final String accessToken;
final bool isDeleted;
final int createdAt;
final int? updatedAt;
final String url;

MemoryShare({
required this.id,
required this.type,
this.metadataCipher,
this.metadataNonce,
required this.encryptedKey,
required this.keyDecryptionNonce,
required this.accessToken,
required this.isDeleted,
required this.createdAt,
this.updatedAt,
required this.url,
});

factory MemoryShare.fromJson(Map<String, dynamic> json) {
return MemoryShare(
id: json['id'] as int,
type: MemoryShareType.fromString(json['type'] as String? ?? 'share'),
metadataCipher: json['metadataCipher'] as String?,
metadataNonce: json['metadataNonce'] as String?,
encryptedKey: json['encryptedKey'] as String,
keyDecryptionNonce: json['keyDecryptionNonce'] as String,
accessToken: json['accessToken'] as String,
isDeleted: json['isDeleted'] as bool? ?? false,
createdAt: json['createdAt'] as int,
updatedAt: json['updatedAt'] as int?,
url: json['url'] as String,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'metadataCipher': metadataCipher,
'metadataNonce': metadataNonce,
'encryptedKey': encryptedKey,
'keyDecryptionNonce': keyDecryptionNonce,
'accessToken': accessToken,
'isDeleted': isDeleted,
'createdAt': createdAt,
'updatedAt': updatedAt,
'url': url,
};
}
}

class MemoryShareFileItem {
final int fileID;
final String encryptedKey;
final String keyDecryptionNonce;

MemoryShareFileItem({
required this.fileID,
required this.encryptedKey,
required this.keyDecryptionNonce,
});

Map<String, dynamic> toJson() {
return {
'fileID': fileID,
'encryptedKey': encryptedKey,
'keyDecryptionNonce': keyDecryptionNonce,
};
}
}
115 changes: 115 additions & 0 deletions mobile/apps/photos/lib/services/memory_share_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:ente_crypto/ente_crypto.dart';
import 'package:fast_base58/fast_base58.dart';
import 'package:photos/core/network/network.dart';
import 'package:photos/db/memory_shares_db.dart';
import 'package:photos/models/api/entity/type.dart';
import 'package:photos/models/api/memory_share/memory_share.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/models/memories/memory.dart';
import 'package:photos/service_locator.dart' show entityService;
import 'package:photos/utils/file_key.dart';

class MemoryShareService {
static final MemoryShareService instance = MemoryShareService._();
MemoryShareService._();

final _enteDio = NetworkClient.instance.enteDio;
final _db = MemorySharesDB.instance;

/// Creates a memory share and returns the shareable URL with key in fragment.
Future<String> createMemoryShare({
required List<EnteFile> files,
required String title,
String? memoryId,
}) async {
final uploadedFiles = files.where((f) => f.uploadedFileID != null).toList();

if (uploadedFiles.isEmpty) {
throw Exception("No uploaded files to share");
}

final memoryEntityKey =
await entityService.getOrCreateEntityKey(EntityType.memory);
final shareKey = CryptoUtil.generateKey();
final encryptedShareKey = CryptoUtil.encryptSync(
shareKey,
memoryEntityKey,
);

final metadataMap = <String, dynamic>{'name': title};
if (memoryId != null) {
metadataMap['memoryId'] = memoryId;
}
final metadata = jsonEncode(metadataMap);
final metadataBytes = utf8.encode(metadata);
final encryptedMetadata = CryptoUtil.encryptSync(
Uint8List.fromList(metadataBytes),
shareKey,
);

final fileItems = <Map<String, dynamic>>[];
for (final file in uploadedFiles) {
final fileKey = getFileKey(file);
final reEncryptedKey = CryptoUtil.encryptSync(fileKey, shareKey);
fileItems.add({
'fileID': file.uploadedFileID,
'encryptedKey': CryptoUtil.bin2base64(reEncryptedKey.encryptedData!),
'keyDecryptionNonce': CryptoUtil.bin2base64(reEncryptedKey.nonce!),
});
}

final requestData = {
'metadataCipher': CryptoUtil.bin2base64(encryptedMetadata.encryptedData!),
'metadataNonce': CryptoUtil.bin2base64(encryptedMetadata.nonce!),
'encryptedKey': CryptoUtil.bin2base64(encryptedShareKey.encryptedData!),
'keyDecryptionNonce': CryptoUtil.bin2base64(encryptedShareKey.nonce!),
'files': fileItems,
};

final response = await _enteDio.post('/memory-share', data: requestData);
final memoryShare = MemoryShare.fromJson(response.data['memoryShare']);

await _db.upsert(memoryShare);

// Key in URL fragment is never sent to server (E2E encryption)
final keyBase58 = Base58Encode(shareKey);
final shareUrl = "${memoryShare.url}#$keyBase58";

return shareUrl;
}

Future<List<MemoryShare>> listMemoryShares() async {
final response = await _enteDio.get('/memory-share');
final List<dynamic> shares = response.data['memoryShares'] ?? [];
final result = shares
.map((s) => MemoryShare.fromJson(s as Map<String, dynamic>))
.toList();

for (final share in result) {
await _db.upsert(share);
}

return result;
}

Future<List<MemoryShare>> getLocalMemoryShares() async {
return _db.getAll();
}

Future<void> deleteMemoryShare(int id) async {
await _enteDio.delete('/memory-share/$id');
await _db.delete(id);
}

Future<String> shareMemories({
required List<Memory> memories,
required String title,
String? memoryId,
}) async {
final files = Memory.filesFromMemories(memories);
return createMemoryShare(files: files, title: title, memoryId: memoryId);
}
}
Loading
Loading