Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef80a8e
feat(mobile): handle Android ACTION_VIEW intent
Feb 5, 2026
bda0ceb
Merge remote-tracking branch 'origin/main' into feature/gallery_app
Feb 6, 2026
bb803f1
feat(mobile): fallback to computed checksum for timeline match
Feb 6, 2026
66c6dae
Merge remote-tracking branch 'origin/main' into feature/gallery_app
Feb 10, 2026
3ab68a4
fix(mobile): proper handling is user authenticated
Feb 10, 2026
bc301a3
feat(mobile): open ACTION_VIEW fallback in AssetViewer
Feb 10, 2026
c35c948
feat(mobile): add logger
Feb 10, 2026
136379a
Merge remote-tracking branch 'origin/main' into feature/gallery_app
Feb 10, 2026
fb66f53
test(mobile): add unit tests for view intent pending/flush flow
Feb 10, 2026
175f8d9
fix(mobile): fix format
Feb 12, 2026
719c7d9
Merge branch 'main' into feature/gallery_app
PeterOmbodi Apr 9, 2026
4806dc7
fix(mobile): remove redundant iOS code
PeterOmbodi Apr 15, 2026
b3b0b0f
refactor(mobile): simplify view intent flow and support file-backed A…
PeterOmbodi Apr 16, 2026
0d4d59c
refactor(mobile): extract MediaStore utils and resolve view intents v…
PeterOmbodi Apr 17, 2026
4354431
refactor(mobile): move deferred view intents into providers, split vi…
PeterOmbodi Apr 17, 2026
275c324
Merge remote-tracking branch 'origin/main' into feature/gallery_app
PeterOmbodi Apr 17, 2026
66a3aa2
refactor(mobile): resolve merge conflicts
PeterOmbodi Apr 17, 2026
80c9796
Merge remote-tracking branch 'origin/main' into feature/gallery_app
PeterOmbodi Apr 17, 2026
2775a09
style(mobile): format files
PeterOmbodi Apr 17, 2026
dc15af4
style(mobile): format files #2
PeterOmbodi Apr 17, 2026
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
16 changes: 16 additions & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@
<data android:mimeType="video/*" />
</intent-filter>

<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
<data android:scheme="file" android:mimeType="image/*" />
</intent-filter>

<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
<data android:scheme="file" android:mimeType="video/*" />
</intent-filter>

<!-- immich:// URL scheme handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.alextran.immich

import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
Expand All @@ -20,6 +21,7 @@ import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine

Expand All @@ -29,6 +31,11 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}

companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
Expand All @@ -51,6 +58,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package app.alextran.immich.media

import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns

object MediaStoreUtils {
private fun externalFilesUri(): Uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Files.getContentUri("external")
}

fun contentUriForMimeType(mimeType: String): Uri =
when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("audio/") -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> externalFilesUri()
}

fun contentUriForAssetType(type: Int): Uri =
when (type) {
// same order as AssetType from dart
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> externalFilesUri()
}

fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? {
val fileName = path.substringAfterLast('/', missingDelimiterValue = path)
val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" }
if (fileName.isBlank()) return null

val (selection, args) =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent)
} else {
"${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName)
}

return queryLatestId(
context = context,
tableUri = contentUriForMimeType(mimeType),
selection = selection,
selectionArgs = args,
)
}

fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}

return queryLatestId(
context = context,
tableUri = contentUriForMimeType(mimeType),
selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
selectionArgs = arrayOf(displayName, size.toString()),
)
}

private fun queryLatestId(
context: Context,
tableUri: Uri,
selection: String,
selectionArgs: Array<String>,
): String? {
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
selection,
selectionArgs,
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ interface NativeSyncApi {
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun hashFiles(paths: List<String>, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
Expand Down Expand Up @@ -548,6 +549,26 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val pathsArg = args[0] as List<String>
api.hashFiles(pathsArg) { 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException

Expand Down Expand Up @@ -417,24 +419,55 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
}

fun hashFiles(
paths: List<String>,
callback: (Result<List<HashResult>>) -> Unit
) {
if (paths.isEmpty()) {
completeWhenActive(callback, Result.success(emptyList()))
return
}

hashTask?.cancel()
hashTask = CoroutineScope(Dispatchers.IO).launch {
try {
val results = paths.map { path ->
async {
hashSemaphore.withPermit {
ensureActive()
hashFile(path)
}
}
}.awaitAll()

completeWhenActive(callback, Result.success(results))
} catch (e: CancellationException) {
completeWhenActive(
callback, Result.failure(
FlutterError(
HASHING_CANCELLED_CODE,
"Hashing operation was cancelled",
null
)
)
)
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
}
}

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) {
currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead)
}
val hashString = ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
hashInputStream(inputStream)
} ?: 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)
Expand All @@ -443,6 +476,35 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
}

private suspend fun hashFile(path: String): HashResult {
return try {
val file = File(path)
if (!file.exists()) {
return HashResult(path, "File does not exist", null)
}

val hashString = FileInputStream(file).use { inputStream ->
hashInputStream(inputStream)
}
HashResult(path, null, hashString)
} catch (e: SecurityException) {
HashResult(path, "Permission denied accessing file: ${e.message}", null)
} catch (e: Exception) {
HashResult(path, "Failed to hash file: ${e.message}", null)
}
}

private suspend fun hashInputStream(inputStream: InputStream): String {
val digest = MessageDigest.getInstance("SHA-1")
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 Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
}

fun cancelHashing() {
hashTask?.cancel()
hashTask = null
Expand Down
Loading
Loading