Compare commits

...

3 Commits

6 changed files with 83 additions and 35 deletions

View File

@@ -4,7 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
@@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
@@ -52,6 +53,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
private const val HASH_ORIGINAL_UNAVAILABLE_CODE = "HASH_ORIGINAL_UNAVAILABLE"
private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
@@ -275,7 +277,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
// if mimeType is webp but not animated, it's just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
@@ -377,7 +379,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
fun getAssetsForAlbum(
albumId: String,
updatedTimeCond: Long?,
callback: (Result<List<PlatformAsset>>) -> Unit
) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
@@ -419,7 +425,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}.awaitAll()
completeWhenActive(callback, Result.success(results))
} catch (e: CancellationException) {
} catch (_: CancellationException) {
completeWhenActive(
callback, Result.failure(
FlutterError(
@@ -436,31 +442,63 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}
private suspend fun hashAsset(assetId: String): HashResult {
val digest = MessageDigest.getInstance("SHA-1")
openOriginalStream(assetId).use { inputStream ->
var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead)
}
}
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
return HashResult(assetId, null, hashString)
}
private fun openOriginalStream(assetId: String): InputStream {
val id = assetId.toLong()
val collection = when (getMediaType(id)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
var uri: Uri = ContentUris.withAppendedId(collection, id)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
uri = MediaStore.setRequireOriginal(uri)
}
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) {
currentCoroutineContext().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)
ctx.contentResolver.openInputStream(uri)
?: throw FlutterError(
HASH_ORIGINAL_UNAVAILABLE_CODE,
"Cannot open original stream for asset $assetId",
null
)
} catch (e: FlutterError) {
throw e
} catch (e: Exception) {
HashResult(assetId, "Failed to hash asset: ${e.message}", null)
throw FlutterError(
HASH_ORIGINAL_UNAVAILABLE_CODE,
"Failed to open original stream for asset $assetId: ${e.message}",
e.stackTraceToString()
)
}
}
private fun getMediaType(id: Long): Int =
getCursor(
MediaStore.VOLUME_EXTERNAL,
"${MediaStore.MediaColumns._ID} = ?",
arrayOf(id.toString()),
arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE)
)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE))
} else {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
}
} ?: MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
fun cancelHashing() {
hashTask?.cancel()
hashTask = null
@@ -476,8 +514,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (_: CancellationException) {
completeWhenActive(
callback,
Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
)
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}

View File

@@ -66,11 +66,12 @@ class HashService {
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
} on PlatformException catch (e) {
} on PlatformException catch (e, s) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
return;
}
_log.severe("Native hashing failed: ${e.code}", e, s);
} catch (e, s) {
_log.severe("Error during hashing", e, s);
}

View File

@@ -44,7 +44,7 @@
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void;
onClick?: (asset: TimelineAsset, event?: MouseEvent) => void;
onPreview?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -93,12 +93,12 @@
}
};
const callClickHandlers = () => {
const callClickHandlers = (e?: MouseEvent) => {
if (selected) {
onIconClickedHandler();
onIconClickedHandler(e);
return;
}
onClick?.($state.snapshot(asset));
onClick?.($state.snapshot(asset), e);
};
const handleClick = (e: MouseEvent) => {
@@ -109,7 +109,7 @@
e.stopPropagation();
e.preventDefault();
callClickHandlers();
callClickHandlers(e);
};
const onMouseEnter = () => {

View File

@@ -59,6 +59,7 @@
groupTitle: string,
asset: TimelineAsset,
) => void,
event?: MouseEvent,
) => void;
}
@@ -685,9 +686,9 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
onClick={(asset, event) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
onThumbnailClick(asset, timelineManager, timelineDay, _onClick, event);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}

View File

@@ -118,7 +118,12 @@
groupTitle: string,
asset: TimelineAsset,
) => void,
event?: MouseEvent,
) => {
if (event?.shiftKey) {
onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
return;
}
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {

View File

@@ -16,7 +16,7 @@ const config = {
preprocess: vitePreprocess(),
kit: {
version: {
name: process.env.IMMICH_BUILD || Date.now().toString(),
name: process.env.IMMICH_BUILD || process.env.npm_package_version || 'local',
},
paths: {
relative: false,