refactor: hashing service (#21997)

* download only backup selected assets

* android impl

* fix tests

* limit concurrent hashing to 16

* extension cleanup

* optimized hashing

* hash only selected albums

* remove concurrency limit

* address review comments

* log more info on failure

* add native cancellation

* small batch size on ios, large on android

* fix: get correct resources

* cleanup getResource

* ios better hash cancellation

* handle graceful cancellation android

* do not trigger multiple hashing ops

* ios: fix circular reference, improve cancellation

* kotlin: more cancellation checks

* no need to create result

* cancel previous task

* avoid race condition

* ensure cancellation gets called

* fix cancellation not happening

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2025-09-18 10:12:37 +05:30 committed by GitHub
parent 2411bf8374
commit 532ec10d5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 662 additions and 269 deletions

View file

@ -209,6 +209,40 @@ data class SyncDelta (
override fun hashCode(): Int = toList().hashCode() override fun hashCode(): Int = toList().hashCode()
} }
/** Generated class from Pigeon that represents data sent in messages. */
data class HashResult (
val assetId: String,
val error: String? = null,
val hash: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): HashResult {
val assetId = pigeonVar_list[0] as String
val error = pigeonVar_list[1] as String?
val hash = pigeonVar_list[2] as String?
return HashResult(assetId, error, hash)
}
}
fun toList(): List<Any?> {
return listOf(
assetId,
error,
hash,
)
}
override fun equals(other: Any?): Boolean {
if (other !is HashResult) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class MessagesPigeonCodec : StandardMessageCodec() { private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return when (type) {
@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
SyncDelta.fromList(it) SyncDelta.fromList(it)
} }
} }
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer) else -> super.readValueOfType(type, buffer)
} }
} }
@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(131) stream.write(131)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is HashResult -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
} }
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi { interface NativeSyncApi {
fun shouldFullSync(): Boolean fun shouldFullSync(): Boolean
@ -259,7 +303,8 @@ interface NativeSyncApi {
fun getAlbums(): List<PlatformAlbum> fun getAlbums(): List<PlatformAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun hashPaths(paths: List<String>): List<ByteArray?> fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@ -402,13 +447,33 @@ interface NativeSyncApi {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val pathsArg = args[0] as List<String> val assetIdsArg = args[0] as List<String>
val allowNetworkAccessArg = args[1] as Boolean
api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result<List<HashResult>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
listOf(api.hashPaths(pathsArg)) api.cancelHashing()
listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception) MessagesPigeonUtils.wrapError(exception)
} }

View file

@ -1,14 +1,25 @@
package app.alextran.immich.sync package app.alextran.immich.sync
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Base64
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
sealed class AssetResult { sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@ -19,8 +30,12 @@ sealed class AssetResult {
open class NativeSyncApiImplBase(context: Context) { open class NativeSyncApiImplBase(context: Context) {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
companion object { companion object {
private const val TAG = "NativeSyncApiImplBase" private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
const val MEDIA_SELECTION = const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
.toList() .toList()
} }
fun hashPaths(paths: List<String>): List<ByteArray?> { fun hashAssets(
val buffer = ByteArray(HASH_BUFFER_SIZE) assetIds: List<String>,
val digest = MessageDigest.getInstance("SHA-1") // allowNetworkAccess is only used on the iOS implementation
@Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
callback: (Result<List<HashResult>>) -> Unit
) {
if (assetIds.isEmpty()) {
callback(Result.success(emptyList()))
return
}
return paths.map { path -> hashTask?.cancel()
hashTask = CoroutineScope(Dispatchers.IO).launch {
try { try {
FileInputStream(path).use { file -> val results = assetIds.map { assetId ->
var bytesRead: Int async {
while (file.read(buffer).also { bytesRead = it } > 0) { hashSemaphore.withPermit {
digest.update(buffer, 0, bytesRead) ensureActive()
hashAsset(assetId)
}
} }
} }.awaitAll()
digest.digest()
callback(Result.success(results))
} catch (e: CancellationException) {
callback(
Result.failure(
FlutterError(
HASHING_CANCELLED_CODE,
"Hashing operation was cancelled",
null
)
)
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to hash file $path: $e") callback(Result.failure(e))
null
} }
} }
} }
private suspend fun hashAsset(assetId: String): HashResult {
return try {
val assetUri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId.toLong()
)
val digest = MessageDigest.getInstance("SHA-1")
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
coroutineContext.ensureActive()
digest.update(buffer, 0, bytesRead)
}
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
HashResult(assetId, null, hashString)
} catch (e: SecurityException) {
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
} catch (e: Exception) {
HashResult(assetId, "Failed to hash asset: ${e.message}", null)
}
}
fun cancelHashing() {
hashTask?.cancel()
hashTask = null
}
} }

View file

@ -4,7 +4,6 @@
*.moved-aside *.moved-aside
*.pbxuser *.pbxuser
*.perspectivev3 *.perspectivev3
**/*sync/
.sconsign.dblite .sconsign.dblite
.tags* .tags*
**/.vagrant/ **/.vagrant/

View file

@ -5,7 +5,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws { func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker() BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker() BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled") print("BackgroundWorkerApiImpl:enable Background worker scheduled")
} }
func configure(settings: BackgroundWorkerSettings) throws { func configure(settings: BackgroundWorkerSettings) throws {
@ -15,7 +15,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func disable() throws { func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID); BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID); BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers") print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
} }
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload" private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"

View file

@ -267,6 +267,39 @@ struct SyncDelta: Hashable {
} }
} }
/// Generated class from Pigeon that represents data sent in messages.
struct HashResult: Hashable {
var assetId: String
var error: String? = nil
var hash: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let hash: String? = nilOrValue(pigeonVar_list[2])
return HashResult(
assetId: assetId,
error: error,
hash: hash
)
}
func toList() -> [Any?] {
return [
assetId,
error,
hash,
]
}
static func == (lhs: HashResult, rhs: HashResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader { private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? { override func readValue(ofType type: UInt8) -> Any? {
switch type { switch type {
@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return PlatformAlbum.fromList(self.readValue() as! [Any?]) return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 131: case 131:
return SyncDelta.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
} }
@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? SyncDelta { } else if let value = value as? SyncDelta {
super.writeByte(131) super.writeByte(131)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(132)
super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
} }
@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
} }
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi { protocol NativeSyncApi {
func shouldFullSync() throws -> Bool func shouldFullSync() throws -> Bool
@ -323,7 +362,8 @@ protocol NativeSyncApi {
func getAlbums() throws -> [PlatformAlbum] func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -459,22 +499,38 @@ class NativeSyncApiSetup {
} else { } else {
getAssetsForAlbumChannel.setMessageHandler(nil) getAssetsForAlbumChannel.setMessageHandler(nil)
} }
let hashPathsChannel = taskQueue == nil let hashAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api { if let api = api {
hashPathsChannel.setMessageHandler { message, reply in hashAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let pathsArg = args[0] as! [String] let assetIdsArg = args[0] as! [String]
let allowNetworkAccessArg = args[1] as! Bool
api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
hashAssetsChannel.setMessageHandler(nil)
}
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelHashingChannel.setMessageHandler { _, reply in
do { do {
let result = try api.hashPaths(paths: pathsArg) try api.cancelHashing()
reply(wrapResult(result)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
} }
} }
} else { } else {
hashPathsChannel.setMessageHandler(nil) cancelHashingChannel.setMessageHandler(nil)
} }
} }
} }

View file

@ -17,30 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
} }
} }
extension PHAsset {
func toPlatformAsset() -> PlatformAsset {
return PlatformAsset(
id: localIdentifier,
name: title(),
type: Int64(mediaType.rawValue),
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite
)
}
}
class NativeSyncApiImpl: NativeSyncApi { class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken" private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219 private let recoveredAlbumSubType = 1000000219
private let hashBufferSize = 2 * 1024 * 1024 private var hashTask: Task<Void, Error>?
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
init(with defaults: UserDefaults = .standard) { init(with defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@ -96,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count { for i in 0..<collections.count {
let album = collections.object(at: i) let album = collections.object(at: i)
// Ignore recovered album // Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) { if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue; continue;
@ -254,7 +240,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
} }
let result = PHAsset.fetchAssets(in: album, options: options) let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 0) { if(result.count == 0) {
return [] return []
@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
return assets return assets
} }
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] { func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
return paths.map { path in if let prevTask = hashTask {
guard let file = FileHandle(forReadingAtPath: path) else { prevTask.cancel()
print("Cannot open file: \(path)") hashTask = nil
return nil }
} hashTask = Task { [weak self] in
var missingAssetIds = Set(assetIds)
var hasher = Insecure.SHA1() var assets = [PHAsset]()
while autoreleasepool(invoking: { assets.reserveCapacity(assetIds.count)
let chunk = file.readData(ofLength: hashBufferSize) PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
guard !chunk.isEmpty else { return false } if Task.isCancelled {
hasher.update(data: chunk) stop.pointee = true
return true return
}) { } }
missingAssetIds.remove(asset.localIdentifier)
let digest = hasher.finalize() assets.append(asset)
return FlutterStandardTypedData(bytes: Data(digest))
} }
if Task.isCancelled {
return completion(Self.hashCancelled)
}
await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]()
results.reserveCapacity(assets.count)
for asset in assets {
if Task.isCancelled {
return completion(Self.hashCancelled)
}
taskGroup.addTask {
guard let self = self else { return nil }
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
}
}
for await result in taskGroup {
guard let result = result else {
return completion(Self.hashCancelled)
}
results.append(result)
}
for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
}
completion(.success(results))
}
}
}
func cancelHashing() {
hashTask?.cancel()
hashTask = nil
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
}
let requestRef = RequestRef()
return await withTaskCancellationHandler(operation: {
if Task.isCancelled {
return nil
}
guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
}
if Task.isCancelled {
return nil
}
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1()
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in
hasher.update(data: data)
},
completionHandler: { error in
let result: HashResult? = switch (error) {
case let e as PHPhotosError where e.code == .userCancelled: nil
case let .some(e): HashResult(
assetId: asset.localIdentifier,
error: "Failed to hash asset: \(e.localizedDescription)",
hash: nil
)
case .none:
HashResult(
assetId: asset.localIdentifier,
error: nil,
hash: Data(hasher.finalize()).base64EncodedString()
)
}
continuation.resume(returning: result)
}
)
}
}, onCancel: {
guard let requestId = requestRef.id else { return }
PHAssetResourceManager.default().cancelDataRequest(requestId)
})
} }
} }

View file

@ -0,0 +1,77 @@
import Photos
extension PHAsset {
func toPlatformAsset() -> PlatformAsset {
return PlatformAsset(
id: localIdentifier,
name: title,
type: Int64(mediaType.rawValue),
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite
)
}
var title: String {
return filename ?? originalFilename ?? "<unknown>"
}
var filename: String? {
return value(forKey: "filename") as? String
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? {
return getResource()?.originalFilename
}
func getResource() -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: self)
let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
guard !filteredResources.isEmpty else {
return nil
}
if filteredResources.count == 1 {
return filteredResources.first
}
if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
return currentResource
}
if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
return fullSizeResource
}
return nil
}
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
switch mediaType {
case .image:
return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
case .video:
return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
default:
return false
}
}
private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
switch mediaType {
case .image:
return type == .fullSizePhoto
case .video:
return type == .fullSizeVideo
default:
return false
}
}
}

View file

@ -0,0 +1,16 @@
import Photos
extension PHAssetResource {
var isCurrent: Bool {
return value(forKey: "isCurrent") as? Bool ?? false
}
var isMediaResource: Bool {
var isMedia = type != .adjustmentData
if #available(iOS 17, *) {
isMedia = isMedia && type != .photoProxy
}
return isMedia
}
}

View file

@ -1,3 +1,5 @@
import 'dart:io';
const int noDbId = -9223372036854775808; // from Isar const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1; const double downloadCompleted = -1;
const double downloadFailed = -2; const double downloadFailed = -2;
@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
const int kFetchLocalAssetsBatchSize = 40000; const int kFetchLocalAssetsBatchSize = 40000;
// Hash batch limits // Hash batch limits
const int kBatchHashFileLimit = 256; final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys // Secure storage keys

View file

@ -184,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try { try {
final backgroundSyncManager = _ref.read(backgroundSyncProvider); final backgroundSyncManager = _ref.read(backgroundSyncProvider);
final nativeSyncApi = _ref.read(nativeSyncApiProvider);
_isCleanedUp = true; _isCleanedUp = true;
_ref.dispose(); _ref.dispose();
@ -199,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_drift.close(), _drift.close(),
_driftLogger.close(), _driftLogger.close(),
backgroundSyncManager.cancel(), backgroundSyncManager.cancel(),
backgroundSyncManager.cancelLocal(), nativeSyncApi.cancelHashing(),
]; ];
if (_isar.isOpen) { if (_isar.isOpen) {

View file

@ -1,20 +1,18 @@
import 'dart:convert'; import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
class HashService { class HashService {
final int batchSizeLimit; final int _batchSize;
final int batchFileLimit;
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final StorageRepository _storageRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
final _log = Logger('HashService'); final _log = Logger('HashService');
@ -22,37 +20,42 @@ class HashService {
HashService({ HashService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker, bool Function()? cancelChecker,
this.batchSizeLimit = kBatchHashSizeLimit, int? batchSize,
this.batchFileLimit = kBatchHashFileLimit,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_storageRepository = storageRepository,
_cancelChecker = cancelChecker, _cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi; _nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async { Future<void> hashAssets() async {
_log.info("Starting hashing of assets"); _log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud try {
final localAlbums = await _localAlbumRepository.getAll( // Sorted by backupSelection followed by isCloud
sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum}, final localAlbums = await _localAlbumRepository.getBackupAlbums();
);
for (final album in localAlbums) { for (final album in localAlbums) {
if (isCancelled) { if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums."); _log.warning("Hashing cancelled. Stopped processing albums.");
break; break;
} }
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) { if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash); await _hashAssets(album, assetsToHash);
}
} }
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
return;
}
} catch (e, s) {
_log.severe("Error during hashing", e, s);
} }
stopwatch.stop(); stopwatch.stop();
@ -63,8 +66,7 @@ class HashService {
/// with hash for those that were successfully hashed. Hashes are looked up in a table /// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async { Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0; final toHash = <String, LocalAsset>{};
final toHash = <_AssetToPath>[];
for (final asset in assetsToHash) { for (final asset in assetsToHash) {
if (isCancelled) { if (isCancelled) {
@ -72,21 +74,10 @@ class HashService {
return; return;
} }
final file = await _storageRepository.getFileForAsset(asset.id); toHash[asset.id] = asset;
if (file == null) { if (toHash.length == _batchSize) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue;
}
bytesProcessed += await file.length();
toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
await _processBatch(album, toHash); await _processBatch(album, toHash);
toHash.clear(); toHash.clear();
bytesProcessed = 0;
} }
} }
@ -94,33 +85,36 @@ class HashService {
} }
/// Processes a batch of assets. /// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async { Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
if (toHash.isEmpty) { if (toHash.isEmpty) {
return; return;
} }
_log.fine("Hashing ${toHash.length} files"); _log.fine("Hashing ${toHash.length} files");
final hashed = <LocalAsset>[]; final hashed = <String, String>{};
final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList()); final hashResults = await _nativeSyncApi.hashAssets(
toHash.keys.toList(),
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
);
assert( assert(
hashes.length == toHash.length, hashResults.length == toHash.length,
"Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}", "Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
); );
for (int i = 0; i < hashes.length; i++) { for (int i = 0; i < hashResults.length; i++) {
if (isCancelled) { if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing batch."); _log.warning("Hashing cancelled. Stopped processing batch.");
return; return;
} }
final hash = hashes[i]; final hashResult = hashResults[i];
final asset = toHash[i].asset; if (hashResult.hash != null) {
if (hash?.length == 20) { hashed[hashResult.assetId] = hashResult.hash!;
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else { } else {
final asset = toHash[hashResult.assetId];
_log.warning( _log.warning(
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}", "Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
); );
} }
} }
@ -128,13 +122,5 @@ class HashService {
_log.fine("Hashed ${hashed.length}/${toHash.length} assets"); _log.fine("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed); await _localAssetRepository.updateHashes(hashed);
await _storageRepository.clearCache();
} }
} }
class _AssetToPath {
final LocalAsset asset;
final String path;
const _AssetToPath({required this.asset, required this.path});
}

View file

@ -36,17 +36,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull(); Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
Future<void> updateHashes(Iterable<LocalAsset> hashes) { Future<void> updateHashes(Map<String, String> hashes) {
if (hashes.isEmpty) { if (hashes.isEmpty) {
return Future.value(); return Future.value();
} }
return _db.batch((batch) async { return _db.batch((batch) async {
for (final asset in hashes) { for (final entry in hashes.entries) {
batch.update( batch.update(
_db.localAssetEntity, _db.localAssetEntity,
LocalAssetEntityCompanion(checksum: Value(asset.checksum)), LocalAssetEntityCompanion(checksum: Value(entry.value)),
where: (e) => e.id.equals(asset.id), where: (e) => e.id.equals(entry.key),
); );
} }
}); });

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
@ -5,12 +6,14 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
@ -64,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
}); });
await _handleLinkedAlbumFuture; await _handleLinkedAlbumFuture;
} }
// Restart backup if total count changed and backup is enabled
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (totalChanged && isBackupEnabled) {
await ref.read(driftBackupProvider.notifier).cancel();
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
}
} }
@override @override
@ -102,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
onPopInvokedWithResult: (didPop, _) async { onPopInvokedWithResult: (didPop, _) async {
if (!didPop) { if (!didPop) {
await _handlePagePopped(); await _handlePagePopped();
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
final nativeSync = ref.read(nativeSyncApiProvider);
if (totalChanged) {
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
unawaited(backupNotifier.cancel().whenComplete(() => backupNotifier.startBackup(user.id)));
}
}
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },

View file

@ -205,6 +205,45 @@ class SyncDelta {
int get hashCode => Object.hashAll(_toList()); int get hashCode => Object.hashAll(_toList());
} }
class HashResult {
HashResult({required this.assetId, this.error, this.hash});
String assetId;
String? error;
String? hash;
List<Object?> _toList() {
return <Object?>[assetId, error, hash];
}
Object encode() {
return _toList();
}
static HashResult decode(Object result) {
result as List<Object?>;
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! HashResult || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@ -221,6 +260,9 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is SyncDelta) { } else if (value is SyncDelta) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is HashResult) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
} }
@ -235,6 +277,8 @@ class _PigeonCodec extends StandardMessageCodec {
return PlatformAlbum.decode(readValue(buffer)!); return PlatformAlbum.decode(readValue(buffer)!);
case 131: case 131:
return SyncDelta.decode(readValue(buffer)!); return SyncDelta.decode(readValue(buffer)!);
case 132:
return HashResult.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
} }
@ -468,15 +512,15 @@ class NativeSyncApi {
} }
} }
Future<List<Uint8List?>> hashPaths(List<String> paths) async { Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[paths]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetIds, allowNetworkAccess]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@ -492,7 +536,30 @@ class NativeSyncApi {
message: 'Host platform returned null value for non-null return value.', message: 'Host platform returned null value for non-null return value.',
); );
} else { } else {
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<Uint8List?>(); return (pigeonVar_replyList[0] as List<Object?>?)!.cast<HashResult>();
}
}
Future<void> cancelHashing() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
} }
} }
} }

View file

@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
final syncStreamServiceProvider = Provider( final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService( (ref) => SyncStreamService(
@ -35,7 +34,6 @@ final hashServiceProvider = Provider(
(ref) => HashService( (ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
), ),
); );

View file

@ -16,9 +16,10 @@ class HashService {
required IsarDeviceAssetRepository deviceAssetRepository, required IsarDeviceAssetRepository deviceAssetRepository,
required BackgroundService backgroundService, required BackgroundService backgroundService,
this.batchSizeLimit = kBatchHashSizeLimit, this.batchSizeLimit = kBatchHashSizeLimit,
this.batchFileLimit = kBatchHashFileLimit, int? batchFileLimit,
}) : _deviceAssetRepository = deviceAssetRepository, }) : _deviceAssetRepository = deviceAssetRepository,
_backgroundService = backgroundService; _backgroundService = backgroundService,
batchFileLimit = batchFileLimit ?? kBatchHashFileLimit;
final IsarDeviceAssetRepository _deviceAssetRepository; final IsarDeviceAssetRepository _deviceAssetRepository;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;

View file

@ -71,6 +71,14 @@ class SyncDelta {
}); });
} }
class HashResult {
final String assetId;
final String? error;
final String? hash;
const HashResult({required this.assetId, this.error, this.hash});
}
@HostApi() @HostApi()
abstract class NativeSyncApi { abstract class NativeSyncApi {
bool shouldFullSync(); bool shouldFullSync();
@ -94,6 +102,9 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond}); List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<Uint8List?> hashPaths(List<String> paths); List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
void cancelHashing();
} }

View file

@ -1,11 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../fixtures/album.stub.dart'; import '../../fixtures/album.stub.dart';
@ -13,192 +9,137 @@ import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart'; import '../service.mock.dart';
class MockFile extends Mock implements File {}
void main() { void main() {
late HashService sut; late HashService sut;
late MockLocalAlbumRepository mockAlbumRepo; late MockLocalAlbumRepository mockAlbumRepo;
late MockLocalAssetRepository mockAssetRepo; late MockLocalAssetRepository mockAssetRepo;
late MockStorageRepository mockStorageRepo;
late MockNativeSyncApi mockNativeApi; late MockNativeSyncApi mockNativeApi;
final sortBy = {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum};
setUp(() { setUp(() {
mockAlbumRepo = MockLocalAlbumRepository(); mockAlbumRepo = MockLocalAlbumRepository();
mockAssetRepo = MockLocalAssetRepository(); mockAssetRepo = MockLocalAssetRepository();
mockStorageRepo = MockStorageRepository();
mockNativeApi = MockNativeSyncApi(); mockNativeApi = MockNativeSyncApi();
sut = HashService( sut = HashService(
localAlbumRepository: mockAlbumRepo, localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo, localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi, nativeSyncApi: mockNativeApi,
); );
registerFallbackValue(LocalAlbumStub.recent); registerFallbackValue(LocalAlbumStub.recent);
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
}); });
group('HashService hashAssets', () { group('HashService hashAssets', () {
test('skips albums with no assets to hash', () async { test('skips albums with no assets to hash', () async {
when( when(
() => mockAlbumRepo.getAll(sortBy: sortBy), () => mockAlbumRepo.getBackupAlbums(),
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]); ).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []); when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
await sut.hashAssets(); await sut.hashAssets();
verifyNever(() => mockStorageRepo.getFileForAsset(any())); verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(() => mockNativeApi.hashPaths(any()));
}); });
}); });
group('HashService _hashAssets', () { group('HashService _hashAssets', () {
test('skips assets without files', () async { test('skips empty batches', () async {
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1; when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]); when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => null);
await sut.hashAssets(); await sut.hashAssets();
verifyNever(() => mockNativeApi.hashPaths(any())); verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
}); });
test('processes assets when available', () async { test('processes assets when available', () async {
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1; final asset = LocalAssetStub.image1;
final mockFile = MockFile();
final hash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockFile.length()).thenAnswer((_) async => 1000); when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockFile.path).thenReturn('image-path');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [hash]); () => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
await sut.hashAssets(); await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1); verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>; final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1); expect(captured.length, 1);
expect(captured[0].checksum, base64.encode(hash)); expect(captured[asset.id], 'test-hash');
}); });
test('handles failed hashes', () async { test('handles failed hashes', () async {
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1; final asset = LocalAssetStub.image1;
final mockFile = MockFile();
when(() => mockFile.length()).thenAnswer((_) async => 1000);
when(() => mockFile.path).thenReturn('image-path');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]); when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [null]); () => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); ).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets(); await sut.hashAssets();
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>; final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0); expect(captured.length, 0);
}); });
test('handles invalid hash length', () async { test('handles null hash results', () async {
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset = LocalAssetStub.image1; final asset = LocalAssetStub.image1;
final mockFile = MockFile();
when(() => mockFile.length()).thenAnswer((_) async => 1000);
when(() => mockFile.path).thenReturn('image-path');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]); when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]); when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mockStorageRepo.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
final invalidHash = Uint8List.fromList([1, 2, 3]); ).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer((_) async => [invalidHash]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
await sut.hashAssets(); await sut.hashAssets();
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>; final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0); expect(captured.length, 0);
}); });
test('batches by file count limit', () async {
final sut = HashService(
localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi,
batchFileLimit: 1,
);
final album = LocalAlbumStub.recent;
final asset1 = LocalAssetStub.image1;
final asset2 = LocalAssetStub.image2;
final mockFile1 = MockFile();
final mockFile2 = MockFile();
when(() => mockFile1.length()).thenAnswer((_) async => 100);
when(() => mockFile1.path).thenReturn('path-1');
when(() => mockFile2.length()).thenAnswer((_) async => 100);
when(() => mockFile2.path).thenReturn('path-2');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
final hash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
verify(() => mockAssetRepo.updateHashes(any())).called(2);
});
test('batches by size limit', () async { test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService( final sut = HashService(
localAlbumRepository: mockAlbumRepo, localAlbumRepository: mockAlbumRepo,
localAssetRepository: mockAssetRepo, localAssetRepository: mockAssetRepo,
storageRepository: mockStorageRepo,
nativeSyncApi: mockNativeApi, nativeSyncApi: mockNativeApi,
batchSizeLimit: 80, batchSize: batchSize,
); );
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset1 = LocalAssetStub.image1; final asset1 = LocalAssetStub.image1;
final asset2 = LocalAssetStub.image2; final asset2 = LocalAssetStub.image2;
final mockFile1 = MockFile(); final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
final mockFile2 = MockFile();
when(() => mockFile1.length()).thenAnswer((_) async => 100);
when(() => mockFile1.path).thenReturn('path-1');
when(() => mockFile2.length()).thenAnswer((_) async => 100);
when(() => mockFile2.path).thenReturn('path-2');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]); final capturedCalls = <List<String>>[];
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1);
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2);
final hash = Uint8List.fromList(List.generate(20, (i) => i));
when(() => mockNativeApi.hashPaths(any())).thenAnswer((_) async => [hash]);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
capturedCalls.add(List<String>.from(assetIds));
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets(); await sut.hashAssets();
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1); expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1); expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
verify(() => mockAssetRepo.updateHashes(any())).called(2); verify(() => mockAssetRepo.updateHashes(any())).called(2);
}); });
@ -206,27 +147,43 @@ void main() {
final album = LocalAlbumStub.recent; final album = LocalAlbumStub.recent;
final asset1 = LocalAssetStub.image1; final asset1 = LocalAssetStub.image1;
final asset2 = LocalAssetStub.image2; final asset2 = LocalAssetStub.image2;
final mockFile1 = MockFile();
final mockFile2 = MockFile();
when(() => mockFile1.length()).thenAnswer((_) async => 100);
when(() => mockFile1.path).thenReturn('path-1');
when(() => mockFile2.length()).thenAnswer((_) async => 100);
when(() => mockFile2.path).thenReturn('path-2');
when(() => mockAlbumRepo.getAll(sortBy: sortBy)).thenAnswer((_) async => [album]); when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]); when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mockStorageRepo.getFileForAsset(asset1.id)).thenAnswer((_) async => mockFile1); when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
when(() => mockStorageRepo.getFileForAsset(asset2.id)).thenAnswer((_) async => mockFile2); (_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
final validHash = Uint8List.fromList(List.generate(20, (i) => i)); HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
when(() => mockNativeApi.hashPaths(['path-1', 'path-2'])).thenAnswer((_) async => [validHash, null]); ],
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); );
await sut.hashAssets(); await sut.hashAssets();
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as List<LocalAsset>; final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1); expect(captured.length, 1);
expect(captured.first.id, asset1.id); expect(captured[asset1.id], 'asset1-hash');
});
test('uses allowNetworkAccess based on album backup selection', () async {
final selectedAlbum = LocalAlbumStub.recent.copyWith(backupSelection: BackupSelection.selected);
final nonSelectedAlbum = LocalAlbumStub.recent.copyWith(id: 'album2', backupSelection: BackupSelection.excluded);
final asset1 = LocalAssetStub.image1;
final asset2 = LocalAssetStub.image2;
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mockAlbumRepo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mockAlbumRepo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets();
verify(() => mockNativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
}); });
}); });
} }