diff --git a/.gitattributes b/.gitattributes
index 2e8a45ca5c..3d43ff20ed 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -9,6 +9,9 @@ mobile/lib/**/*.g.dart linguist-generated=true
 mobile/lib/**/*.drift.dart -diff -merge
 mobile/lib/**/*.drift.dart linguist-generated=true
 
+mobile/drift_schemas/main/drift_schema_*.json -diff -merge
+mobile/drift_schemas/main/drift_schema_*.json linguist-generated=true
+
 open-api/typescript-sdk/fetch-client.ts -diff -merge
 open-api/typescript-sdk/fetch-client.ts linguist-generated=true
 
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 0fcc4f1d7c..33912d687c 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -93,6 +93,10 @@ jobs:
         run: make translation
         working-directory: ./mobile
 
+      - name: Generate platform APIs
+        run: make pigeon
+        working-directory: ./mobile
+
       - name: Build Android App Bundle
         working-directory: ./mobile
         env:
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 7cd28228dc..754c0c38b3 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -59,13 +59,17 @@ jobs:
         working-directory: ./mobile
 
       - name: Generate translation file
-        run: make translation; dart format lib/generated/codegen_loader.g.dart
+        run: make translation
         working-directory: ./mobile
 
       - name: Run Build Runner
         run: make build
         working-directory: ./mobile
 
+      - name: Generate platform API
+        run: make pigeon
+        working-directory: ./mobile
+
       - name: Find file changes
         uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
         id: verify-changed-files
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index 854f852e3c..dc81c10dec 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -55,6 +55,7 @@ custom_lint:
       restrict: package:photo_manager
       allowed:
         # required / wanted
+        - 'lib/infrastructure/repositories/album_media.repository.dart'
         - 'lib/repositories/{album,asset,file}_media.repository.dart'
         # acceptable exceptions for the time being
         - lib/entities/asset.entity.dart # to provide local AssetEntity for now
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 0ec511d9f1..7455ae99a2 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -1,103 +1,106 @@
 plugins {
-    id "com.android.application"
-    id "kotlin-android"
-    id "dev.flutter.flutter-gradle-plugin"
-    id 'com.google.devtools.ksp'
+  id "com.android.application"
+  id "kotlin-android"
+  id "dev.flutter.flutter-gradle-plugin"
+  id 'com.google.devtools.ksp'
 }
 
 def localProperties = new Properties()
 def localPropertiesFile = rootProject.file('local.properties')
 if (localPropertiesFile.exists()) {
-    localPropertiesFile.withInputStream { localProperties.load(it) }
+  localPropertiesFile.withInputStream { localProperties.load(it) }
 }
 
 def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
 if (flutterVersionCode == null) {
-    flutterVersionCode = '1'
+  flutterVersionCode = '1'
 }
 
 def flutterVersionName = localProperties.getProperty('flutter.versionName')
 if (flutterVersionName == null) {
-    flutterVersionName = '1.0'
+  flutterVersionName = '1.0'
 }
 
 def keystoreProperties = new Properties()
 def keystorePropertiesFile = rootProject.file('key.properties')
 if (keystorePropertiesFile.exists()) {
-    keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
+  keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
 }
 
 android {
-    compileSdkVersion 35
+  compileSdkVersion 35
 
-    compileOptions {
-        sourceCompatibility JavaVersion.VERSION_17
-        targetCompatibility JavaVersion.VERSION_17
-        coreLibraryDesugaringEnabled true
+  compileOptions {
+    sourceCompatibility JavaVersion.VERSION_17
+    targetCompatibility JavaVersion.VERSION_17
+    coreLibraryDesugaringEnabled true
+  }
+
+  kotlinOptions {
+    jvmTarget = '17'
+  }
+
+  sourceSets {
+    main.java.srcDirs += 'src/main/kotlin'
+  }
+
+  defaultConfig {
+    applicationId "app.alextran.immich"
+    minSdkVersion 26
+    targetSdkVersion 35
+    versionCode flutterVersionCode.toInteger()
+    versionName flutterVersionName
+  }
+
+  signingConfigs {
+    release {
+      def keyAliasVal = System.getenv("ALIAS")
+      def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
+      def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
+
+      keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
+      keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
+      storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
+      storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
+    }
+  }
+
+  buildTypes {
+    debug {
+      applicationIdSuffix '.debug'
+      versionNameSuffix '-DEBUG'
     }
 
-    kotlinOptions {
-        jvmTarget = '17'
+    release {
+      signingConfig signingConfigs.release
     }
-
-    sourceSets {
-        main.java.srcDirs += 'src/main/kotlin'
-    }
-
-    defaultConfig {
-        applicationId "app.alextran.immich"
-        minSdkVersion 26
-        targetSdkVersion 35
-        versionCode flutterVersionCode.toInteger()
-        versionName flutterVersionName
-    }
-
-   signingConfigs {
-       release {
-            def keyAliasVal = System.getenv("ALIAS")
-            def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
-            def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
-
-            keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
-            keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
-            storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
-            storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
-       }
-   }
-
-    buildTypes {
-        debug {
-            applicationIdSuffix '.debug'
-            versionNameSuffix '-DEBUG'
-        }
-
-        release {
-            signingConfig signingConfigs.release
-        }
-    }
-    namespace 'app.alextran.immich'
+  }
+  namespace 'app.alextran.immich'
 }
 
 flutter {
-    source '../..'
+  source '../..'
 }
 
 dependencies {
-    def kotlin_version = '2.0.20'
-    def kotlin_coroutines_version = '1.9.0'
-    def work_version = '2.9.1'
-    def concurrent_version = '1.2.0'
-    def guava_version = '33.3.1-android'
-    def glide_version = '4.16.0'
+  def kotlin_version = '2.0.20'
+  def kotlin_coroutines_version = '1.9.0'
+  def work_version = '2.9.1'
+  def concurrent_version = '1.2.0'
+  def guava_version = '33.3.1-android'
+  def glide_version = '4.16.0'
+  def serialization_version = '1.8.1'
 
-    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
-    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
-    implementation "androidx.work:work-runtime-ktx:$work_version"
-    implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
-    implementation "com.google.guava:guava:$guava_version"
-    implementation "com.github.bumptech.glide:glide:$glide_version"
-    ksp "com.github.bumptech.glide:ksp:$glide_version"
-    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
+  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
+  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
+  implementation "androidx.work:work-runtime-ktx:$work_version"
+  implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
+  implementation "com.google.guava:guava:$guava_version"
+  implementation "com.github.bumptech.glide:glide:$glide_version"
+  implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
+
+  ksp "com.github.bumptech.glide:ksp:$glide_version"
+  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
 }
 
 // This is uncommented in F-Droid build script
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index c1e5152d28..f9c4ee2a1f 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -1,6 +1,11 @@
 package app.alextran.immich
 
+import android.os.Build
+import android.os.ext.SdkExtensions
 import androidx.annotation.NonNull
+import app.alextran.immich.sync.NativeSyncApi
+import app.alextran.immich.sync.NativeSyncApiImpl26
+import app.alextran.immich.sync.NativeSyncApiImpl30
 import io.flutter.embedding.android.FlutterFragmentActivity
 import io.flutter.embedding.engine.FlutterEngine
 
@@ -10,5 +15,13 @@ class MainActivity : FlutterFragmentActivity() {
     flutterEngine.plugins.add(BackgroundServicePlugin())
     flutterEngine.plugins.add(HttpSSLOptionsPlugin())
     // No need to set up method channel here as it's now handled in the plugin
+
+    val nativeSyncApiImpl =
+      if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
+        NativeSyncApiImpl26(this)
+      } else {
+        NativeSyncApiImpl30(this)
+      }
+    NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
   }
 }
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
new file mode 100644
index 0000000000..f4dbda730b
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -0,0 +1,393 @@
+// Autogenerated from Pigeon (v25.3.2), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+package app.alextran.immich.sync
+
+import android.util.Log
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMethodCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+private object MessagesPigeonUtils {
+
+  fun wrapResult(result: Any?): List<Any?> {
+    return listOf(result)
+  }
+
+  fun wrapError(exception: Throwable): List<Any?> {
+    return if (exception is FlutterError) {
+      listOf(
+        exception.code,
+        exception.message,
+        exception.details
+      )
+    } else {
+      listOf(
+        exception.javaClass.simpleName,
+        exception.toString(),
+        "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+      )
+    }
+  }
+  fun deepEquals(a: Any?, b: Any?): Boolean {
+    if (a is ByteArray && b is ByteArray) {
+        return a.contentEquals(b)
+    }
+    if (a is IntArray && b is IntArray) {
+        return a.contentEquals(b)
+    }
+    if (a is LongArray && b is LongArray) {
+        return a.contentEquals(b)
+    }
+    if (a is DoubleArray && b is DoubleArray) {
+        return a.contentEquals(b)
+    }
+    if (a is Array<*> && b is Array<*>) {
+      return a.size == b.size &&
+          a.indices.all{ deepEquals(a[it], b[it]) }
+    }
+    if (a is List<*> && b is List<*>) {
+      return a.size == b.size &&
+          a.indices.all{ deepEquals(a[it], b[it]) }
+    }
+    if (a is Map<*, *> && b is Map<*, *>) {
+      return a.size == b.size && a.all {
+          (b as Map<Any?, Any?>).containsKey(it.key) &&
+          deepEquals(it.value, b[it.key])
+      }
+    }
+    return a == b
+  }
+      
+}
+
+/**
+ * Error class for passing custom error details to Flutter via a thrown PlatformException.
+ * @property code The error code.
+ * @property message The error message.
+ * @property details The error details. Must be a datatype supported by the api codec.
+ */
+class FlutterError (
+  val code: String,
+  override val message: String? = null,
+  val details: Any? = null
+) : Throwable()
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class PlatformAsset (
+  val id: String,
+  val name: String,
+  val type: Long,
+  val createdAt: Long? = null,
+  val updatedAt: Long? = null,
+  val durationInSeconds: Long
+)
+ {
+  companion object {
+    fun fromList(pigeonVar_list: List<Any?>): PlatformAsset {
+      val id = pigeonVar_list[0] as String
+      val name = pigeonVar_list[1] as String
+      val type = pigeonVar_list[2] as Long
+      val createdAt = pigeonVar_list[3] as Long?
+      val updatedAt = pigeonVar_list[4] as Long?
+      val durationInSeconds = pigeonVar_list[5] as Long
+      return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds)
+    }
+  }
+  fun toList(): List<Any?> {
+    return listOf(
+      id,
+      name,
+      type,
+      createdAt,
+      updatedAt,
+      durationInSeconds,
+    )
+  }
+  override fun equals(other: Any?): Boolean {
+    if (other !is PlatformAsset) {
+      return false
+    }
+    if (this === other) {
+      return true
+    }
+    return MessagesPigeonUtils.deepEquals(toList(), other.toList())  }
+
+  override fun hashCode(): Int = toList().hashCode()
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class PlatformAlbum (
+  val id: String,
+  val name: String,
+  val updatedAt: Long? = null,
+  val isCloud: Boolean,
+  val assetCount: Long
+)
+ {
+  companion object {
+    fun fromList(pigeonVar_list: List<Any?>): PlatformAlbum {
+      val id = pigeonVar_list[0] as String
+      val name = pigeonVar_list[1] as String
+      val updatedAt = pigeonVar_list[2] as Long?
+      val isCloud = pigeonVar_list[3] as Boolean
+      val assetCount = pigeonVar_list[4] as Long
+      return PlatformAlbum(id, name, updatedAt, isCloud, assetCount)
+    }
+  }
+  fun toList(): List<Any?> {
+    return listOf(
+      id,
+      name,
+      updatedAt,
+      isCloud,
+      assetCount,
+    )
+  }
+  override fun equals(other: Any?): Boolean {
+    if (other !is PlatformAlbum) {
+      return false
+    }
+    if (this === other) {
+      return true
+    }
+    return MessagesPigeonUtils.deepEquals(toList(), other.toList())  }
+
+  override fun hashCode(): Int = toList().hashCode()
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class SyncDelta (
+  val hasChanges: Boolean,
+  val updates: List<PlatformAsset>,
+  val deletes: List<String>,
+  val assetAlbums: Map<String, List<String>>
+)
+ {
+  companion object {
+    fun fromList(pigeonVar_list: List<Any?>): SyncDelta {
+      val hasChanges = pigeonVar_list[0] as Boolean
+      val updates = pigeonVar_list[1] as List<PlatformAsset>
+      val deletes = pigeonVar_list[2] as List<String>
+      val assetAlbums = pigeonVar_list[3] as Map<String, List<String>>
+      return SyncDelta(hasChanges, updates, deletes, assetAlbums)
+    }
+  }
+  fun toList(): List<Any?> {
+    return listOf(
+      hasChanges,
+      updates,
+      deletes,
+      assetAlbums,
+    )
+  }
+  override fun equals(other: Any?): Boolean {
+    if (other !is SyncDelta) {
+      return false
+    }
+    if (this === other) {
+      return true
+    }
+    return MessagesPigeonUtils.deepEquals(toList(), other.toList())  }
+
+  override fun hashCode(): Int = toList().hashCode()
+}
+private open class MessagesPigeonCodec : StandardMessageCodec() {
+  override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+    return when (type) {
+      129.toByte() -> {
+        return (readValue(buffer) as? List<Any?>)?.let {
+          PlatformAsset.fromList(it)
+        }
+      }
+      130.toByte() -> {
+        return (readValue(buffer) as? List<Any?>)?.let {
+          PlatformAlbum.fromList(it)
+        }
+      }
+      131.toByte() -> {
+        return (readValue(buffer) as? List<Any?>)?.let {
+          SyncDelta.fromList(it)
+        }
+      }
+      else -> super.readValueOfType(type, buffer)
+    }
+  }
+  override fun writeValue(stream: ByteArrayOutputStream, value: Any?)   {
+    when (value) {
+      is PlatformAsset -> {
+        stream.write(129)
+        writeValue(stream, value.toList())
+      }
+      is PlatformAlbum -> {
+        stream.write(130)
+        writeValue(stream, value.toList())
+      }
+      is SyncDelta -> {
+        stream.write(131)
+        writeValue(stream, value.toList())
+      }
+      else -> super.writeValue(stream, value)
+    }
+  }
+}
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
+interface NativeSyncApi {
+  fun shouldFullSync(): Boolean
+  fun getMediaChanges(): SyncDelta
+  fun checkpointSync()
+  fun clearSyncCheckpoint()
+  fun getAssetIdsForAlbum(albumId: String): List<String>
+  fun getAlbums(): List<PlatformAlbum>
+  fun getAssetsCountSince(albumId: String, timestamp: Long): Long
+  fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
+
+  companion object {
+    /** The codec used by NativeSyncApi. */
+    val codec: MessageCodec<Any?> by lazy {
+      MessagesPigeonCodec()
+    }
+    /** Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`. */
+    @JvmOverloads
+    fun setUp(binaryMessenger: BinaryMessenger, api: NativeSyncApi?, messageChannelSuffix: String = "") {
+      val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+      val taskQueue = binaryMessenger.makeBackgroundTaskQueue()
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
+        if (api != null) {
+          channel.setMessageHandler { _, reply ->
+            val wrapped: List<Any?> = try {
+              listOf(api.shouldFullSync())
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
+        if (api != null) {
+          channel.setMessageHandler { _, reply ->
+            val wrapped: List<Any?> = try {
+              listOf(api.getMediaChanges())
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$separatedMessageChannelSuffix", codec)
+        if (api != null) {
+          channel.setMessageHandler { _, reply ->
+            val wrapped: List<Any?> = try {
+              api.checkpointSync()
+              listOf(null)
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$separatedMessageChannelSuffix", codec)
+        if (api != null) {
+          channel.setMessageHandler { _, reply ->
+            val wrapped: List<Any?> = try {
+              api.clearSyncCheckpoint()
+              listOf(null)
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
+        if (api != null) {
+          channel.setMessageHandler { message, reply ->
+            val args = message as List<Any?>
+            val albumIdArg = args[0] as String
+            val wrapped: List<Any?> = try {
+              listOf(api.getAssetIdsForAlbum(albumIdArg))
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
+        if (api != null) {
+          channel.setMessageHandler { _, reply ->
+            val wrapped: List<Any?> = try {
+              listOf(api.getAlbums())
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$separatedMessageChannelSuffix", codec, taskQueue)
+        if (api != null) {
+          channel.setMessageHandler { message, reply ->
+            val args = message as List<Any?>
+            val albumIdArg = args[0] as String
+            val timestampArg = args[1] as Long
+            val wrapped: List<Any?> = try {
+              listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+      run {
+        val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
+        if (api != null) {
+          channel.setMessageHandler { message, reply ->
+            val args = message as List<Any?>
+            val albumIdArg = args[0] as String
+            val updatedTimeCondArg = args[1] as Long?
+            val wrapped: List<Any?> = try {
+              listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
+            } catch (exception: Throwable) {
+              MessagesPigeonUtils.wrapError(exception)
+            }
+            reply.reply(wrapped)
+          }
+        } else {
+          channel.setMessageHandler(null)
+        }
+      }
+    }
+  }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
new file mode 100644
index 0000000000..5deacc30db
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
@@ -0,0 +1,24 @@
+package app.alextran.immich.sync
+
+import android.content.Context
+
+
+class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
+  override fun shouldFullSync(): Boolean {
+    return true
+  }
+
+  // No-op for Android 10 and below
+  override fun checkpointSync() {
+    // Cannot throw exception as this is called from the Dart side
+    // during the full sync process as well
+  }
+
+  override fun clearSyncCheckpoint() {
+    // No-op for Android 10 and below
+  }
+
+  override fun getMediaChanges(): SyncDelta {
+    throw IllegalStateException("Method not supported on this Android version.")
+  }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
new file mode 100644
index 0000000000..052032e143
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
@@ -0,0 +1,89 @@
+package app.alextran.immich.sync
+
+import android.content.Context
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresExtension
+import kotlinx.serialization.json.Json
+
+@RequiresApi(Build.VERSION_CODES.Q)
+@RequiresExtension(extension = Build.VERSION_CODES.R, version = 1)
+class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
+  private val ctx: Context = context.applicationContext
+  private val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
+
+  companion object {
+    const val SHARED_PREF_NAME = "Immich::MediaManager"
+    const val SHARED_PREF_MEDIA_STORE_VERSION_KEY = "MediaStore::getVersion"
+    const val SHARED_PREF_MEDIA_STORE_GEN_KEY = "MediaStore::getGeneration"
+  }
+
+  private fun getSavedGenerationMap(): Map<String, Long> {
+    return prefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)?.let {
+      Json.decodeFromString<Map<String, Long>>(it)
+    } ?: emptyMap()
+  }
+
+  override fun clearSyncCheckpoint() {
+    prefs.edit().apply {
+      remove(SHARED_PREF_MEDIA_STORE_VERSION_KEY)
+      remove(SHARED_PREF_MEDIA_STORE_GEN_KEY)
+      apply()
+    }
+  }
+
+  override fun shouldFullSync(): Boolean =
+    MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
+
+  override fun checkpointSync() {
+    val genMap = MediaStore.getExternalVolumeNames(ctx)
+      .associateWith { MediaStore.getGeneration(ctx, it) }
+
+    prefs.edit().apply {
+      putString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, MediaStore.getVersion(ctx))
+      putString(SHARED_PREF_MEDIA_STORE_GEN_KEY, Json.encodeToString(genMap))
+      apply()
+    }
+  }
+
+  override fun getMediaChanges(): SyncDelta {
+    val genMap = getSavedGenerationMap()
+    val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
+    val changed = mutableListOf<PlatformAsset>()
+    val deleted = mutableListOf<String>()
+    val assetAlbums = mutableMapOf<String, List<String>>()
+    var hasChanges = genMap.keys != currentVolumes
+
+    for (volume in currentVolumes) {
+      val currentGen = MediaStore.getGeneration(ctx, volume)
+      val storedGen = genMap[volume] ?: 0
+      if (currentGen <= storedGen) {
+        continue
+      }
+
+      hasChanges = true
+
+      val selection =
+        "$MEDIA_SELECTION AND (${MediaStore.MediaColumns.GENERATION_MODIFIED} > ? OR ${MediaStore.MediaColumns.GENERATION_ADDED} > ?)"
+      val selectionArgs = arrayOf(
+        *MEDIA_SELECTION_ARGS,
+        storedGen.toString(),
+        storedGen.toString()
+      )
+
+      getAssets(getCursor(volume, selection, selectionArgs)).forEach {
+        when (it) {
+          is AssetResult.ValidAsset -> {
+            changed.add(it.asset)
+            assetAlbums[it.asset.id] = listOf(it.albumId)
+          }
+
+          is AssetResult.InvalidAsset -> deleted.add(it.assetId)
+        }
+      }
+    }
+    // Unmounted volumes are handled in dart when the album is removed
+    return SyncDelta(hasChanges, changed, deleted, assetAlbums)
+  }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
new file mode 100644
index 0000000000..2322855307
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -0,0 +1,177 @@
+package app.alextran.immich.sync
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.database.Cursor
+import android.provider.MediaStore
+import java.io.File
+
+sealed class AssetResult {
+  data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
+  data class InvalidAsset(val assetId: String) : AssetResult()
+}
+
+@SuppressLint("InlinedApi")
+open class NativeSyncApiImplBase(context: Context) {
+  private val ctx: Context = context.applicationContext
+
+  companion object {
+    const val MEDIA_SELECTION =
+      "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
+    val MEDIA_SELECTION_ARGS = arrayOf(
+      MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
+      MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
+    )
+    const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)"
+    val ASSET_PROJECTION = arrayOf(
+      MediaStore.MediaColumns._ID,
+      MediaStore.MediaColumns.DATA,
+      MediaStore.MediaColumns.DISPLAY_NAME,
+      MediaStore.MediaColumns.DATE_TAKEN,
+      MediaStore.MediaColumns.DATE_ADDED,
+      MediaStore.MediaColumns.DATE_MODIFIED,
+      MediaStore.Files.FileColumns.MEDIA_TYPE,
+      MediaStore.MediaColumns.BUCKET_ID,
+      MediaStore.MediaColumns.DURATION
+    )
+  }
+
+  protected fun getCursor(
+    volume: String,
+    selection: String,
+    selectionArgs: Array<String>,
+    projection: Array<String> = ASSET_PROJECTION,
+    sortOrder: String? = null
+  ): Cursor? = ctx.contentResolver.query(
+    MediaStore.Files.getContentUri(volume),
+    projection,
+    selection,
+    selectionArgs,
+    sortOrder,
+  )
+
+  protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
+    return sequence {
+      cursor?.use { c ->
+        val idColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
+        val dataColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
+        val nameColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
+        val dateTakenColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
+        val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
+        val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
+        val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
+        val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
+        val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
+
+        while (c.moveToNext()) {
+          val id = c.getLong(idColumn).toString()
+
+          val path = c.getString(dataColumn)
+          if (path.isNullOrBlank() || !File(path).exists()) {
+            yield(AssetResult.InvalidAsset(id))
+            continue
+          }
+
+          val mediaType = c.getInt(mediaTypeColumn)
+          val name = c.getString(nameColumn)
+          // Date taken is milliseconds since epoch, Date added is seconds since epoch
+          val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
+            ?: c.getLong(dateAddedColumn)
+          // Date modified is seconds since epoch
+          val modifiedAt = c.getLong(dateModifiedColumn)
+          // Duration is milliseconds
+          val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
+          else c.getLong(durationColumn) / 1000
+          val bucketId = c.getString(bucketIdColumn)
+
+          val asset = PlatformAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration)
+          yield(AssetResult.ValidAsset(asset, bucketId))
+        }
+      }
+    }
+  }
+
+  fun getAlbums(): List<PlatformAlbum> {
+    val albums = mutableListOf<PlatformAlbum>()
+    val albumsCount = mutableMapOf<String, Int>()
+
+    val projection = arrayOf(
+      MediaStore.Files.FileColumns.BUCKET_ID,
+      MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME,
+      MediaStore.Files.FileColumns.DATE_MODIFIED,
+    )
+    val selection =
+      "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION"
+
+    getCursor(
+      MediaStore.VOLUME_EXTERNAL,
+      selection,
+      MEDIA_SELECTION_ARGS,
+      projection,
+      "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
+    )?.use { cursor ->
+      val bucketIdColumn =
+        cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID)
+      val bucketNameColumn =
+        cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME)
+      val dateModified =
+        cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
+
+      while (cursor.moveToNext()) {
+        val id = cursor.getString(bucketIdColumn)
+
+        val count = albumsCount.getOrDefault(id, 0)
+        if (count != 0) {
+          albumsCount[id] = count + 1
+          continue
+        }
+
+        val name = cursor.getString(bucketNameColumn)
+        val updatedAt = cursor.getLong(dateModified)
+        albums.add(PlatformAlbum(id, name, updatedAt, false, 0))
+        albumsCount[id] = 1
+      }
+    }
+
+    return albums.map { it.copy(assetCount = albumsCount[it.id]?.toLong() ?: 0) }
+      .sortedBy { it.id }
+  }
+
+  fun getAssetIdsForAlbum(albumId: String): List<String> {
+    val projection = arrayOf(MediaStore.MediaColumns._ID)
+
+    return getCursor(
+      MediaStore.VOLUME_EXTERNAL,
+      "$BUCKET_SELECTION AND $MEDIA_SELECTION",
+      arrayOf(albumId, *MEDIA_SELECTION_ARGS),
+      projection
+    )?.use { cursor ->
+      val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
+      generateSequence {
+        if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null
+      }.toList()
+    } ?: emptyList()
+  }
+
+  fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
+    getCursor(
+      MediaStore.VOLUME_EXTERNAL,
+      "$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
+      arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
+    )?.use { cursor -> cursor.count.toLong() } ?: 0L
+
+
+  fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
+    var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
+    val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
+
+    if (updatedTimeCond != null) {
+      selection += " AND (${MediaStore.Files.FileColumns.DATE_MODIFIED} > ? OR ${MediaStore.Files.FileColumns.DATE_ADDED} > ?)"
+      selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString()))
+    }
+
+    return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray()))
+      .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
+      .toList()
+  }
+}
diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle
index 74f8904a10..29c3a7c056 100644
--- a/mobile/android/settings.gradle
+++ b/mobile/android/settings.gradle
@@ -1,26 +1,27 @@
 pluginManagement {
-    def flutterSdkPath = {
-        def properties = new Properties()
-        file("local.properties").withInputStream { properties.load(it) }
-        def flutterSdkPath = properties.getProperty("flutter.sdk")
-        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
-        return flutterSdkPath
-    }()
+  def flutterSdkPath = {
+    def properties = new Properties()
+    file("local.properties").withInputStream { properties.load(it) }
+    def flutterSdkPath = properties.getProperty("flutter.sdk")
+    assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+    return flutterSdkPath
+  }()
 
-    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+  includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
 
-    repositories {
-        google()
-        mavenCentral()
-        gradlePluginPortal()
-    }
+  repositories {
+    google()
+    mavenCentral()
+    gradlePluginPortal()
+  }
 }
 
 plugins {
-    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
-    id "com.android.application" version '8.7.2' apply false
-    id "org.jetbrains.kotlin.android" version "2.0.20" apply false
-    id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false
+  id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+  id "com.android.application" version '8.7.2' apply false
+  id "org.jetbrains.kotlin.android" version "2.0.20" apply false
+  id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false
+  id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false
 }
 
 include ":app"
diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock
index 6d4630f1fb..263a43c22c 100644
--- a/mobile/immich_lint/pubspec.lock
+++ b/mobile/immich_lint/pubspec.lock
@@ -5,31 +5,26 @@ packages:
     dependency: transitive
     description:
       name: _fe_analyzer_shared
-      sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
+      sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
       url: "https://pub.dev"
     source: hosted
-    version: "76.0.0"
-  _macros:
-    dependency: transitive
-    description: dart
-    source: sdk
-    version: "0.3.3"
+    version: "80.0.0"
   analyzer:
     dependency: "direct main"
     description:
       name: analyzer
-      sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
+      sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
       url: "https://pub.dev"
     source: hosted
-    version: "6.11.0"
+    version: "7.3.0"
   analyzer_plugin:
     dependency: "direct main"
     description:
       name: analyzer_plugin
-      sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
+      sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
       url: "https://pub.dev"
     source: hosted
-    version: "0.11.3"
+    version: "0.13.0"
   args:
     dependency: transitive
     description:
@@ -106,34 +101,42 @@ packages:
     dependency: transitive
     description:
       name: custom_lint
-      sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545"
+      sha256: "409c485fd14f544af1da965d5a0d160ee57cd58b63eeaa7280a4f28cf5bda7f1"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
   custom_lint_builder:
     dependency: "direct main"
     description:
       name: custom_lint_builder
-      sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78"
+      sha256: "107e0a43606138015777590ee8ce32f26ba7415c25b722ff0908a6f5d7a4c228"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
   custom_lint_core:
     dependency: transitive
     description:
       name: custom_lint_core
-      sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6"
+      sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
+  custom_lint_visitor:
+    dependency: transitive
+    description:
+      name: custom_lint_visitor
+      sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0+7.3.0"
   dart_style:
     dependency: transitive
     description:
       name: dart_style
-      sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820"
+      sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
       url: "https://pub.dev"
     source: hosted
-    version: "2.3.8"
+    version: "3.1.0"
   file:
     dependency: transitive
     description:
@@ -154,10 +157,10 @@ packages:
     dependency: transitive
     description:
       name: freezed_annotation
-      sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
+      sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
       url: "https://pub.dev"
     source: hosted
-    version: "2.4.4"
+    version: "3.0.0"
   glob:
     dependency: "direct main"
     description:
@@ -198,14 +201,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.0"
-  macros:
-    dependency: transitive
-    description:
-      name: macros
-      sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
-      url: "https://pub.dev"
-    source: hosted
-    version: "0.1.3-main.0"
   matcher:
     dependency: transitive
     description:
@@ -367,4 +362,4 @@ packages:
     source: hosted
     version: "3.1.3"
 sdks:
-  dart: ">=3.6.0 <4.0.0"
+  dart: ">=3.7.0 <4.0.0"
diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml
index 4cfd8abe81..2890a4a595 100644
--- a/mobile/immich_lint/pubspec.yaml
+++ b/mobile/immich_lint/pubspec.yaml
@@ -5,9 +5,9 @@ environment:
   sdk: '>=3.0.0 <4.0.0'
 
 dependencies:
-  analyzer: ^6.0.0
-  analyzer_plugin: ^0.11.3
-  custom_lint_builder: ^0.6.4
+  analyzer: ^7.0.0
+  analyzer_plugin: ^0.13.0
+  custom_lint_builder: ^0.7.5
   glob: ^2.1.2
 
 dev_dependencies:
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 311f19857b..3cbbf83f01 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -89,6 +89,16 @@
 		FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+		B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
+			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+			);
+			path = Sync;
+			sourceTree = "<group>";
+		};
+/* End PBXFileSystemSynchronizedRootGroup section */
+
 /* Begin PBXFrameworksBuildPhase section */
 		97C146EB1CF9000F007C117D /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
@@ -175,6 +185,7 @@
 		97C146F01CF9000F007C117D /* Runner */ = {
 			isa = PBXGroup;
 			children = (
+				B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
 				FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
 				65DD438629917FAD0047FFA8 /* BackgroundSync */,
 				FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
@@ -224,6 +235,9 @@
 			dependencies = (
 				FAC6F8992D287C890078CB2F /* PBXTargetDependency */,
 			);
+			fileSystemSynchronizedGroups = (
+				B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
+			);
 			name = Runner;
 			productName = Runner;
 			productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
@@ -270,7 +284,6 @@
 				};
 			};
 			buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
-			compatibilityVersion = "Xcode 9.3";
 			developmentRegion = en;
 			hasScannedForEncodings = 0;
 			knownRegions = (
@@ -278,6 +291,7 @@
 				Base,
 			);
 			mainGroup = 97C146E51CF9000F007C117D;
+			preferredProjectObjectVersion = 77;
 			productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
 			projectDirPath = "";
 			projectRoot = "";
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index fd62618205..55d08adc6a 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -22,6 +22,9 @@ import UIKit
     BackgroundServicePlugin.registerBackgroundProcessing()
 
     BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
+    
+    let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
+    NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl())
 
     BackgroundServicePlugin.setPluginRegistrantCallback { registry in
       if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
new file mode 100644
index 0000000000..0d7a302688
--- /dev/null
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -0,0 +1,446 @@
+// Autogenerated from Pigeon (v25.3.2), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+import Foundation
+
+#if os(iOS)
+  import Flutter
+#elseif os(macOS)
+  import FlutterMacOS
+#else
+  #error("Unsupported platform.")
+#endif
+
+/// Error class for passing custom error details to Dart side.
+final class PigeonError: Error {
+  let code: String
+  let message: String?
+  let details: Sendable?
+
+  init(code: String, message: String?, details: Sendable?) {
+    self.code = code
+    self.message = message
+    self.details = details
+  }
+
+  var localizedDescription: String {
+    return
+      "PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
+  }
+}
+
+private func wrapResult(_ result: Any?) -> [Any?] {
+  return [result]
+}
+
+private func wrapError(_ error: Any) -> [Any?] {
+  if let pigeonError = error as? PigeonError {
+    return [
+      pigeonError.code,
+      pigeonError.message,
+      pigeonError.details,
+    ]
+  }
+  if let flutterError = error as? FlutterError {
+    return [
+      flutterError.code,
+      flutterError.message,
+      flutterError.details,
+    ]
+  }
+  return [
+    "\(error)",
+    "\(type(of: error))",
+    "Stacktrace: \(Thread.callStackSymbols)",
+  ]
+}
+
+private func isNullish(_ value: Any?) -> Bool {
+  return value is NSNull || value == nil
+}
+
+private func nilOrValue<T>(_ value: Any?) -> T? {
+  if value is NSNull { return nil }
+  return value as! T?
+}
+
+func deepEqualsMessages(_ lhs: Any?, _ rhs: Any?) -> Bool {
+  let cleanLhs = nilOrValue(lhs) as Any?
+  let cleanRhs = nilOrValue(rhs) as Any?
+  switch (cleanLhs, cleanRhs) {
+  case (nil, nil):
+    return true
+
+  case (nil, _), (_, nil):
+    return false
+
+  case is (Void, Void):
+    return true
+
+  case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
+    return cleanLhsHashable == cleanRhsHashable
+
+  case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
+    guard cleanLhsArray.count == cleanRhsArray.count else { return false }
+    for (index, element) in cleanLhsArray.enumerated() {
+      if !deepEqualsMessages(element, cleanRhsArray[index]) {
+        return false
+      }
+    }
+    return true
+
+  case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
+    guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
+    for (key, cleanLhsValue) in cleanLhsDictionary {
+      guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
+      if !deepEqualsMessages(cleanLhsValue, cleanRhsDictionary[key]!) {
+        return false
+      }
+    }
+    return true
+
+  default:
+    // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
+    return false
+  }
+}
+
+func deepHashMessages(value: Any?, hasher: inout Hasher) {
+  if let valueList = value as? [AnyHashable] {
+     for item in valueList { deepHashMessages(value: item, hasher: &hasher) }
+     return
+  }
+
+  if let valueDict = value as? [AnyHashable: AnyHashable] {
+    for key in valueDict.keys { 
+      hasher.combine(key)
+      deepHashMessages(value: valueDict[key]!, hasher: &hasher)
+    }
+    return
+  }
+
+  if let hashableValue = value as? AnyHashable {
+    hasher.combine(hashableValue.hashValue)
+  }
+
+  return hasher.combine(String(describing: value))
+}
+
+    
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct PlatformAsset: Hashable {
+  var id: String
+  var name: String
+  var type: Int64
+  var createdAt: Int64? = nil
+  var updatedAt: Int64? = nil
+  var durationInSeconds: Int64
+
+
+  // swift-format-ignore: AlwaysUseLowerCamelCase
+  static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAsset? {
+    let id = pigeonVar_list[0] as! String
+    let name = pigeonVar_list[1] as! String
+    let type = pigeonVar_list[2] as! Int64
+    let createdAt: Int64? = nilOrValue(pigeonVar_list[3])
+    let updatedAt: Int64? = nilOrValue(pigeonVar_list[4])
+    let durationInSeconds = pigeonVar_list[5] as! Int64
+
+    return PlatformAsset(
+      id: id,
+      name: name,
+      type: type,
+      createdAt: createdAt,
+      updatedAt: updatedAt,
+      durationInSeconds: durationInSeconds
+    )
+  }
+  func toList() -> [Any?] {
+    return [
+      id,
+      name,
+      type,
+      createdAt,
+      updatedAt,
+      durationInSeconds,
+    ]
+  }
+  static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
+    return deepEqualsMessages(lhs.toList(), rhs.toList())  }
+  func hash(into hasher: inout Hasher) {
+    deepHashMessages(value: toList(), hasher: &hasher)
+  }
+}
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct PlatformAlbum: Hashable {
+  var id: String
+  var name: String
+  var updatedAt: Int64? = nil
+  var isCloud: Bool
+  var assetCount: Int64
+
+
+  // swift-format-ignore: AlwaysUseLowerCamelCase
+  static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAlbum? {
+    let id = pigeonVar_list[0] as! String
+    let name = pigeonVar_list[1] as! String
+    let updatedAt: Int64? = nilOrValue(pigeonVar_list[2])
+    let isCloud = pigeonVar_list[3] as! Bool
+    let assetCount = pigeonVar_list[4] as! Int64
+
+    return PlatformAlbum(
+      id: id,
+      name: name,
+      updatedAt: updatedAt,
+      isCloud: isCloud,
+      assetCount: assetCount
+    )
+  }
+  func toList() -> [Any?] {
+    return [
+      id,
+      name,
+      updatedAt,
+      isCloud,
+      assetCount,
+    ]
+  }
+  static func == (lhs: PlatformAlbum, rhs: PlatformAlbum) -> Bool {
+    return deepEqualsMessages(lhs.toList(), rhs.toList())  }
+  func hash(into hasher: inout Hasher) {
+    deepHashMessages(value: toList(), hasher: &hasher)
+  }
+}
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct SyncDelta: Hashable {
+  var hasChanges: Bool
+  var updates: [PlatformAsset]
+  var deletes: [String]
+  var assetAlbums: [String: [String]]
+
+
+  // swift-format-ignore: AlwaysUseLowerCamelCase
+  static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? {
+    let hasChanges = pigeonVar_list[0] as! Bool
+    let updates = pigeonVar_list[1] as! [PlatformAsset]
+    let deletes = pigeonVar_list[2] as! [String]
+    let assetAlbums = pigeonVar_list[3] as! [String: [String]]
+
+    return SyncDelta(
+      hasChanges: hasChanges,
+      updates: updates,
+      deletes: deletes,
+      assetAlbums: assetAlbums
+    )
+  }
+  func toList() -> [Any?] {
+    return [
+      hasChanges,
+      updates,
+      deletes,
+      assetAlbums,
+    ]
+  }
+  static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool {
+    return deepEqualsMessages(lhs.toList(), rhs.toList())  }
+  func hash(into hasher: inout Hasher) {
+    deepHashMessages(value: toList(), hasher: &hasher)
+  }
+}
+
+private class MessagesPigeonCodecReader: FlutterStandardReader {
+  override func readValue(ofType type: UInt8) -> Any? {
+    switch type {
+    case 129:
+      return PlatformAsset.fromList(self.readValue() as! [Any?])
+    case 130:
+      return PlatformAlbum.fromList(self.readValue() as! [Any?])
+    case 131:
+      return SyncDelta.fromList(self.readValue() as! [Any?])
+    default:
+      return super.readValue(ofType: type)
+    }
+  }
+}
+
+private class MessagesPigeonCodecWriter: FlutterStandardWriter {
+  override func writeValue(_ value: Any) {
+    if let value = value as? PlatformAsset {
+      super.writeByte(129)
+      super.writeValue(value.toList())
+    } else if let value = value as? PlatformAlbum {
+      super.writeByte(130)
+      super.writeValue(value.toList())
+    } else if let value = value as? SyncDelta {
+      super.writeByte(131)
+      super.writeValue(value.toList())
+    } else {
+      super.writeValue(value)
+    }
+  }
+}
+
+private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
+  override func reader(with data: Data) -> FlutterStandardReader {
+    return MessagesPigeonCodecReader(data: data)
+  }
+
+  override func writer(with data: NSMutableData) -> FlutterStandardWriter {
+    return MessagesPigeonCodecWriter(data: data)
+  }
+}
+
+class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
+  static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
+}
+
+/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
+protocol NativeSyncApi {
+  func shouldFullSync() throws -> Bool
+  func getMediaChanges() throws -> SyncDelta
+  func checkpointSync() throws
+  func clearSyncCheckpoint() throws
+  func getAssetIdsForAlbum(albumId: String) throws -> [String]
+  func getAlbums() throws -> [PlatformAlbum]
+  func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
+  func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
+}
+
+/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
+class NativeSyncApiSetup {
+  static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared }
+  /// Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`.
+  static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeSyncApi?, messageChannelSuffix: String = "") {
+    let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+    #if os(iOS)
+      let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
+    #else
+      let taskQueue: FlutterTaskQueue? = nil
+    #endif
+    let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+    if let api = api {
+      shouldFullSyncChannel.setMessageHandler { _, reply in
+        do {
+          let result = try api.shouldFullSync()
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      shouldFullSyncChannel.setMessageHandler(nil)
+    }
+    let getMediaChangesChannel = taskQueue == nil
+      ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+      : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+    if let api = api {
+      getMediaChangesChannel.setMessageHandler { _, reply in
+        do {
+          let result = try api.getMediaChanges()
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getMediaChangesChannel.setMessageHandler(nil)
+    }
+    let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+    if let api = api {
+      checkpointSyncChannel.setMessageHandler { _, reply in
+        do {
+          try api.checkpointSync()
+          reply(wrapResult(nil))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      checkpointSyncChannel.setMessageHandler(nil)
+    }
+    let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+    if let api = api {
+      clearSyncCheckpointChannel.setMessageHandler { _, reply in
+        do {
+          try api.clearSyncCheckpoint()
+          reply(wrapResult(nil))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      clearSyncCheckpointChannel.setMessageHandler(nil)
+    }
+    let getAssetIdsForAlbumChannel = taskQueue == nil
+      ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+      : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+    if let api = api {
+      getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
+        let args = message as! [Any?]
+        let albumIdArg = args[0] as! String
+        do {
+          let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getAssetIdsForAlbumChannel.setMessageHandler(nil)
+    }
+    let getAlbumsChannel = taskQueue == nil
+      ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+      : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+    if let api = api {
+      getAlbumsChannel.setMessageHandler { _, reply in
+        do {
+          let result = try api.getAlbums()
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getAlbumsChannel.setMessageHandler(nil)
+    }
+    let getAssetsCountSinceChannel = taskQueue == nil
+      ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+      : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+    if let api = api {
+      getAssetsCountSinceChannel.setMessageHandler { message, reply in
+        let args = message as! [Any?]
+        let albumIdArg = args[0] as! String
+        let timestampArg = args[1] as! Int64
+        do {
+          let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getAssetsCountSinceChannel.setMessageHandler(nil)
+    }
+    let getAssetsForAlbumChannel = taskQueue == nil
+      ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+      : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+    if let api = api {
+      getAssetsForAlbumChannel.setMessageHandler { message, reply in
+        let args = message as! [Any?]
+        let albumIdArg = args[0] as! String
+        let updatedTimeCondArg: Int64? = nilOrValue(args[1])
+        do {
+          let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getAssetsForAlbumChannel.setMessageHandler(nil)
+    }
+  }
+}
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
new file mode 100644
index 0000000000..5d2f08691d
--- /dev/null
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -0,0 +1,246 @@
+import Photos
+
+struct AssetWrapper: Hashable, Equatable {
+  let asset: PlatformAsset
+  
+  init(with asset: PlatformAsset) {
+    self.asset = asset
+  }
+  
+  func hash(into hasher: inout Hasher) {
+    hasher.combine(self.asset.id)
+  }
+  
+  static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
+    return lhs.asset.id == rhs.asset.id
+  }
+}
+
+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) },
+      durationInSeconds: Int64(duration)
+    )
+  }
+}
+
+class NativeSyncApiImpl: NativeSyncApi {
+  private let defaults: UserDefaults
+  private let changeTokenKey = "immich:changeToken"
+  private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
+  
+  init(with defaults: UserDefaults = .standard) {
+    self.defaults = defaults
+  }
+  
+  @available(iOS 16, *)
+  private func getChangeToken() -> PHPersistentChangeToken? {
+    guard let data = defaults.data(forKey: changeTokenKey) else {
+      return nil
+    }
+    return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
+  }
+  
+  @available(iOS 16, *)
+  private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
+    guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
+      return
+    }
+    defaults.set(data, forKey: changeTokenKey)
+  }
+  
+  func clearSyncCheckpoint() -> Void {
+    defaults.removeObject(forKey: changeTokenKey)
+  }
+  
+  func checkpointSync() {
+    guard #available(iOS 16, *) else {
+      return
+    }
+    saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
+  }
+  
+  func shouldFullSync() -> Bool {
+    guard #available(iOS 16, *),
+          PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
+          let storedToken = getChangeToken() else {
+      // When we do not have access to photo library, older iOS version or No token available, fallback to full sync
+      return true
+    }
+    
+    guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
+      // Cannot fetch persistent changes
+      return true
+    }
+    
+    return false
+  }
+  
+  func getAlbums() throws -> [PlatformAlbum] {
+    var albums: [PlatformAlbum] = []
+    
+    albumTypes.forEach { type in
+      let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
+      collections.enumerateObjects { (album, _, _) in
+        let options = PHFetchOptions()
+        options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
+        let assets = PHAsset.fetchAssets(in: album, options: options)
+        let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
+        
+        var domainAlbum = PlatformAlbum(
+          id: album.localIdentifier,
+          name: album.localizedTitle!,
+          updatedAt: nil,
+          isCloud: isCloud,
+          assetCount: Int64(assets.count)
+        )
+        
+        if let firstAsset = assets.firstObject {
+          domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
+        }
+        
+        albums.append(domainAlbum)
+      }
+    }
+    return albums.sorted { $0.id < $1.id }
+  }
+  
+  func getMediaChanges() throws -> SyncDelta {
+    guard #available(iOS 16, *) else {
+      throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
+    }
+    
+    guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
+      throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
+    }
+    
+    guard let storedToken = getChangeToken() else {
+      // No token exists, definitely need a full sync
+      print("MediaManager::getMediaChanges: No token found")
+      throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
+    }
+    
+    let currentToken = PHPhotoLibrary.shared().currentChangeToken
+    if storedToken == currentToken {
+      return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
+    }
+    
+    do {
+      let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
+      
+      var updatedAssets: Set<AssetWrapper> = []
+      var deletedAssets: Set<String> = []
+      
+      for change in changes {
+        guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
+        
+        let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
+        deletedAssets.formUnion(details.deletedLocalIdentifiers)
+        
+        if (updated.isEmpty) { continue }
+        
+        let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil)
+        for i in 0..<result.count {
+          let asset = result.object(at: i)
+          
+          // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
+          let predicate = PlatformAsset(
+            id: asset.localIdentifier,
+            name: "",
+            type: 0,
+            createdAt: nil,
+            updatedAt: nil,
+            durationInSeconds: 0
+          )
+          if (updatedAssets.contains(AssetWrapper(with: predicate))) {
+            continue
+          }
+          
+          let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
+          updatedAssets.insert(domainAsset)
+        }
+      }
+      
+      let updates = Array(updatedAssets.map { $0.asset })
+      return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
+    }
+  }
+  
+  
+  private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
+    guard !assets.isEmpty else {
+      return [:]
+    }
+    
+    var albumAssets: [String: [String]] = [:]
+    
+    for type in albumTypes {
+      let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
+      collections.enumerateObjects { (album, _, _) in
+        let options = PHFetchOptions()
+        options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
+        let result = PHAsset.fetchAssets(in: album, options: options)
+        result.enumerateObjects { (asset, _, _) in
+          albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
+        }
+      }
+    }
+    return albumAssets
+  }
+  
+  func getAssetIdsForAlbum(albumId: String) throws -> [String] {
+    let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
+    guard let album = collections.firstObject else {
+      return []
+    }
+    
+    var ids: [String] = []
+    let assets = PHAsset.fetchAssets(in: album, options: nil)
+    assets.enumerateObjects { (asset, _, _) in
+      ids.append(asset.localIdentifier)
+    }
+    return ids
+  }
+  
+  func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
+    let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
+    guard let album = collections.firstObject else {
+      return 0
+    }
+    
+    let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
+    let options = PHFetchOptions()
+    options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
+    let assets = PHAsset.fetchAssets(in: album, options: options)
+    return Int64(assets.count)
+  }
+  
+  func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
+    let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
+    guard let album = collections.firstObject else {
+      return []
+    }
+    
+    let options = PHFetchOptions()
+    if(updatedTimeCond != nil) {
+      let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
+      options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
+    }
+
+    let result = PHAsset.fetchAssets(in: album, options: options)
+    if(result.count == 0) {
+      return []
+    }
+    
+    var assets: [PlatformAsset] = []
+    result.enumerateObjects { (asset, _, _) in
+      assets.append(asset.toPlatformAsset())
+    }
+    return assets
+  }
+}
diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart
index 33683afd92..8c95922a3a 100644
--- a/mobile/lib/constants/constants.dart
+++ b/mobile/lib/constants/constants.dart
@@ -7,6 +7,7 @@ const int kLogTruncateLimit = 250;
 
 // Sync
 const int kSyncEventBatchSize = 5000;
+const int kFetchLocalAssetsBatchSize = 40000;
 
 // Hash batch limits
 const int kBatchHashFileLimit = 128;
diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart
new file mode 100644
index 0000000000..35cfad4455
--- /dev/null
+++ b/mobile/lib/domain/interfaces/local_album.interface.dart
@@ -0,0 +1,34 @@
+import 'package:immich_mobile/domain/interfaces/db.interface.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/local_album.model.dart';
+
+abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
+  Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy});
+
+  Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
+
+  Future<List<String>> getAssetIdsForAlbum(String albumId);
+
+  Future<void> upsert(
+    LocalAlbum album, {
+    Iterable<LocalAsset> toUpsert = const [],
+    Iterable<String> toDelete = const [],
+  });
+
+  Future<void> updateAll(Iterable<LocalAlbum> albums);
+
+  Future<void> delete(String albumId);
+
+  Future<void> processDelta({
+    required List<LocalAsset> updates,
+    required List<String> deletes,
+    required Map<String, List<String>> assetAlbums,
+  });
+
+  Future<void> syncAlbumDeletes(
+    String albumId,
+    Iterable<String> assetIdsToKeep,
+  );
+}
+
+enum SortLocalAlbumsBy { id }
diff --git a/mobile/lib/domain/models/asset/asset.model.dart b/mobile/lib/domain/models/asset/asset.model.dart
new file mode 100644
index 0000000000..e2bb1fc49f
--- /dev/null
+++ b/mobile/lib/domain/models/asset/asset.model.dart
@@ -0,0 +1,47 @@
+part of 'base_asset.model.dart';
+
+// Model for an asset stored in the server
+class Asset extends BaseAsset {
+  final String id;
+  final String? localId;
+
+  const Asset({
+    required this.id,
+    this.localId,
+    required super.name,
+    required super.checksum,
+    required super.type,
+    required super.createdAt,
+    required super.updatedAt,
+    super.width,
+    super.height,
+    super.durationInSeconds,
+    super.isFavorite = false,
+  });
+
+  @override
+  String toString() {
+    return '''Asset {
+   id: $id,
+   name: $name,
+   type: $type,
+   createdAt: $createdAt,
+   updatedAt: $updatedAt,
+   width: ${width ?? "<NA>"},
+   height: ${height ?? "<NA>"},
+   durationInSeconds: ${durationInSeconds ?? "<NA>"},
+   localId: ${localId ?? "<NA>"},
+   isFavorite: $isFavorite,
+ }''';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Asset) return false;
+    if (identical(this, other)) return true;
+    return super == other && id == other.id && localId == other.localId;
+  }
+
+  @override
+  int get hashCode => super.hashCode ^ id.hashCode ^ localId.hashCode;
+}
diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart
new file mode 100644
index 0000000000..fb95437659
--- /dev/null
+++ b/mobile/lib/domain/models/asset/base_asset.model.dart
@@ -0,0 +1,76 @@
+part 'asset.model.dart';
+part 'local_asset.model.dart';
+
+enum AssetType {
+  // do not change this order!
+  other,
+  image,
+  video,
+  audio,
+}
+
+sealed class BaseAsset {
+  final String name;
+  final String? checksum;
+  final AssetType type;
+  final DateTime createdAt;
+  final DateTime updatedAt;
+  final int? width;
+  final int? height;
+  final int? durationInSeconds;
+  final bool isFavorite;
+
+  const BaseAsset({
+    required this.name,
+    required this.checksum,
+    required this.type,
+    required this.createdAt,
+    required this.updatedAt,
+    this.width,
+    this.height,
+    this.durationInSeconds,
+    this.isFavorite = false,
+  });
+
+  @override
+  String toString() {
+    return '''BaseAsset {
+  name: $name,
+  type: $type,
+  createdAt: $createdAt,
+  updatedAt: $updatedAt,
+  width: ${width ?? "<NA>"},
+  height: ${height ?? "<NA>"},
+  durationInSeconds: ${durationInSeconds ?? "<NA>"},
+  isFavorite: $isFavorite,
+}''';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    if (other is BaseAsset) {
+      return name == other.name &&
+          type == other.type &&
+          createdAt == other.createdAt &&
+          updatedAt == other.updatedAt &&
+          width == other.width &&
+          height == other.height &&
+          durationInSeconds == other.durationInSeconds &&
+          isFavorite == other.isFavorite;
+    }
+    return false;
+  }
+
+  @override
+  int get hashCode {
+    return name.hashCode ^
+        type.hashCode ^
+        createdAt.hashCode ^
+        updatedAt.hashCode ^
+        width.hashCode ^
+        height.hashCode ^
+        durationInSeconds.hashCode ^
+        isFavorite.hashCode;
+  }
+}
diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart
new file mode 100644
index 0000000000..25e617d8ed
--- /dev/null
+++ b/mobile/lib/domain/models/asset/local_asset.model.dart
@@ -0,0 +1,74 @@
+part of 'base_asset.model.dart';
+
+class LocalAsset extends BaseAsset {
+  final String id;
+  final String? remoteId;
+
+  const LocalAsset({
+    required this.id,
+    this.remoteId,
+    required super.name,
+    super.checksum,
+    required super.type,
+    required super.createdAt,
+    required super.updatedAt,
+    super.width,
+    super.height,
+    super.durationInSeconds,
+    super.isFavorite = false,
+  });
+
+  @override
+  String toString() {
+    return '''LocalAsset {
+   id: $id,
+   name: $name,
+   type: $type,
+   createdAt: $createdAt,
+   updatedAt: $updatedAt,
+   width: ${width ?? "<NA>"},
+   height: ${height ?? "<NA>"},
+   durationInSeconds: ${durationInSeconds ?? "<NA>"},
+   remoteId: ${remoteId ?? "<NA>"}
+   isFavorite: $isFavorite,
+ }''';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! LocalAsset) return false;
+    if (identical(this, other)) return true;
+    return super == other && id == other.id && remoteId == other.remoteId;
+  }
+
+  @override
+  int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode;
+
+  LocalAsset copyWith({
+    String? id,
+    String? remoteId,
+    String? name,
+    String? checksum,
+    AssetType? type,
+    DateTime? createdAt,
+    DateTime? updatedAt,
+    int? width,
+    int? height,
+    int? durationInSeconds,
+    bool? isFavorite,
+  }) {
+    return LocalAsset(
+      id: id ?? this.id,
+      remoteId: remoteId ?? this.remoteId,
+      name: name ?? this.name,
+      checksum: checksum ?? this.checksum,
+      type: type ?? this.type,
+      createdAt: createdAt ?? this.createdAt,
+      updatedAt: updatedAt ?? this.updatedAt,
+      width: width ?? this.width,
+      height: height ?? this.height,
+      durationInSeconds: durationInSeconds ?? this.durationInSeconds,
+      isFavorite: isFavorite ?? this.isFavorite,
+    );
+  }
+}
diff --git a/mobile/lib/domain/models/local_album.model.dart b/mobile/lib/domain/models/local_album.model.dart
new file mode 100644
index 0000000000..95c56627bb
--- /dev/null
+++ b/mobile/lib/domain/models/local_album.model.dart
@@ -0,0 +1,70 @@
+enum BackupSelection {
+  none,
+  selected,
+  excluded,
+}
+
+class LocalAlbum {
+  final String id;
+  final String name;
+  final DateTime updatedAt;
+
+  final int assetCount;
+  final BackupSelection backupSelection;
+
+  const LocalAlbum({
+    required this.id,
+    required this.name,
+    required this.updatedAt,
+    this.assetCount = 0,
+    this.backupSelection = BackupSelection.none,
+  });
+
+  LocalAlbum copyWith({
+    String? id,
+    String? name,
+    DateTime? updatedAt,
+    int? assetCount,
+    BackupSelection? backupSelection,
+  }) {
+    return LocalAlbum(
+      id: id ?? this.id,
+      name: name ?? this.name,
+      updatedAt: updatedAt ?? this.updatedAt,
+      assetCount: assetCount ?? this.assetCount,
+      backupSelection: backupSelection ?? this.backupSelection,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! LocalAlbum) return false;
+    if (identical(this, other)) return true;
+
+    return other.id == id &&
+        other.name == name &&
+        other.updatedAt == updatedAt &&
+        other.assetCount == assetCount &&
+        other.backupSelection == backupSelection;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        name.hashCode ^
+        updatedAt.hashCode ^
+        assetCount.hashCode ^
+        backupSelection.hashCode;
+  }
+
+  @override
+  String toString() {
+    return '''LocalAlbum: {
+id: $id,
+name: $name,
+updatedAt: $updatedAt,
+assetCount: $assetCount,
+backupSelection: $backupSelection,
+}''';
+  }
+}
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
new file mode 100644
index 0000000000..e07595b6db
--- /dev/null
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -0,0 +1,379 @@
+import 'dart:async';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/widgets.dart';
+import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/local_album.model.dart';
+import 'package:immich_mobile/domain/models/store.model.dart';
+import 'package:immich_mobile/domain/services/store.service.dart';
+import 'package:immich_mobile/platform/native_sync_api.g.dart';
+import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
+import 'package:immich_mobile/utils/diff.dart';
+import 'package:logging/logging.dart';
+import 'package:platform/platform.dart';
+
+class LocalSyncService {
+  final ILocalAlbumRepository _localAlbumRepository;
+  final NativeSyncApi _nativeSyncApi;
+  final Platform _platform;
+  final StoreService _storeService;
+  final Logger _log = Logger("DeviceSyncService");
+
+  LocalSyncService({
+    required ILocalAlbumRepository localAlbumRepository,
+    required NativeSyncApi nativeSyncApi,
+    required StoreService storeService,
+    Platform? platform,
+  })  : _localAlbumRepository = localAlbumRepository,
+        _nativeSyncApi = nativeSyncApi,
+        _storeService = storeService,
+        _platform = platform ?? const LocalPlatform();
+
+  bool get _ignoreIcloudAssets =>
+      _storeService.get(StoreKey.ignoreIcloudAssets, false) == true;
+
+  Future<void> sync({bool full = false}) async {
+    final Stopwatch stopwatch = Stopwatch()..start();
+    try {
+      if (full || await _nativeSyncApi.shouldFullSync()) {
+        _log.fine("Full sync request from ${full ? "user" : "native"}");
+        DLog.log("Full sync request from ${full ? "user" : "native"}");
+        return await fullSync();
+      }
+
+      final delta = await _nativeSyncApi.getMediaChanges();
+      if (!delta.hasChanges) {
+        _log.fine("No media changes detected. Skipping sync");
+        DLog.log("No media changes detected. Skipping sync");
+        return;
+      }
+
+      DLog.log("Delta updated: ${delta.updates.length}");
+      DLog.log("Delta deleted: ${delta.deletes.length}");
+
+      final deviceAlbums = await _nativeSyncApi.getAlbums();
+      await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
+      await _localAlbumRepository.processDelta(
+        updates: delta.updates.toLocalAssets(),
+        deletes: delta.deletes,
+        assetAlbums: delta.assetAlbums,
+      );
+
+      final dbAlbums = await _localAlbumRepository.getAll();
+      // On Android, we need to sync all albums since it is not possible to
+      // detect album deletions from the native side
+      if (_platform.isAndroid) {
+        for (final album in dbAlbums) {
+          final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
+          await _localAlbumRepository.syncAlbumDeletes(album.id, deviceIds);
+        }
+      }
+
+      if (_platform.isIOS) {
+        // On iOS, we need to full sync albums that are marked as cloud as the delta sync
+        // does not include changes for cloud albums. If ignoreIcloudAssets is enabled,
+        // remove the albums from the local database from the previous sync
+        final cloudAlbums =
+            deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
+        for (final album in cloudAlbums) {
+          final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
+          if (dbAlbum == null) {
+            _log.warning(
+              "Cloud album ${album.name} not found in local database. Skipping sync.",
+            );
+            continue;
+          }
+          if (_ignoreIcloudAssets) {
+            await removeAlbum(dbAlbum);
+          } else {
+            await updateAlbum(dbAlbum, album);
+          }
+        }
+      }
+
+      await _nativeSyncApi.checkpointSync();
+    } catch (e, s) {
+      _log.severe("Error performing device sync", e, s);
+    } finally {
+      stopwatch.stop();
+      _log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
+      DLog.log("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
+    }
+  }
+
+  Future<void> fullSync() async {
+    try {
+      final Stopwatch stopwatch = Stopwatch()..start();
+
+      List<PlatformAlbum> deviceAlbums =
+          List.of(await _nativeSyncApi.getAlbums());
+      if (_platform.isIOS && _ignoreIcloudAssets) {
+        deviceAlbums.removeWhere((album) => album.isCloud);
+      }
+
+      final dbAlbums =
+          await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id);
+
+      await diffSortedLists(
+        dbAlbums,
+        deviceAlbums.toLocalAlbums(),
+        compare: (a, b) => a.id.compareTo(b.id),
+        both: updateAlbum,
+        onlyFirst: removeAlbum,
+        onlySecond: addAlbum,
+      );
+
+      await _nativeSyncApi.checkpointSync();
+      stopwatch.stop();
+      _log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
+      DLog.log("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
+    } catch (e, s) {
+      _log.severe("Error performing full device sync", e, s);
+    }
+  }
+
+  Future<void> addAlbum(LocalAlbum album) async {
+    try {
+      _log.fine("Adding device album ${album.name}");
+
+      final assets = album.assetCount > 0
+          ? await _nativeSyncApi.getAssetsForAlbum(album.id)
+          : <PlatformAsset>[];
+
+      await _localAlbumRepository.upsert(
+        album,
+        toUpsert: assets.toLocalAssets(),
+      );
+      _log.fine("Successfully added device album ${album.name}");
+    } catch (e, s) {
+      _log.warning("Error while adding device album", e, s);
+    }
+  }
+
+  Future<void> removeAlbum(LocalAlbum a) async {
+    _log.fine("Removing device album ${a.name}");
+    try {
+      // Asset deletion is handled in the repository
+      await _localAlbumRepository.delete(a.id);
+    } catch (e, s) {
+      _log.warning("Error while removing device album", e, s);
+    }
+  }
+
+  // The deviceAlbum is ignored since we are going to refresh it anyways
+  FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
+    try {
+      _log.fine("Syncing device album ${dbAlbum.name}");
+
+      if (_albumsEqual(deviceAlbum, dbAlbum)) {
+        _log.fine(
+          "Device album ${dbAlbum.name} has not changed. Skipping sync.",
+        );
+        return false;
+      }
+
+      _log.fine("Device album ${dbAlbum.name} has changed. Syncing...");
+
+      // Faster path - only new assets added
+      if (await checkAddition(dbAlbum, deviceAlbum)) {
+        _log.fine("Fast synced device album ${dbAlbum.name}");
+        DLog.log("Fast synced device album ${dbAlbum.name}");
+        return true;
+      }
+
+      // Slower path - full sync
+      return await fullDiff(dbAlbum, deviceAlbum);
+    } catch (e, s) {
+      _log.warning("Error while diff device album", e, s);
+    }
+    return true;
+  }
+
+  @visibleForTesting
+  // The [deviceAlbum] is expected to be refreshed before calling this method
+  // with modified time and asset count
+  Future<bool> checkAddition(
+    LocalAlbum dbAlbum,
+    LocalAlbum deviceAlbum,
+  ) async {
+    try {
+      _log.fine("Fast syncing device album ${dbAlbum.name}");
+      // Assets has been modified
+      if (deviceAlbum.assetCount <= dbAlbum.assetCount) {
+        _log.fine("Local album has modifications. Proceeding to full sync");
+        return false;
+      }
+
+      final updatedTime =
+          (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1;
+      final newAssetsCount =
+          await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime);
+
+      // Early return if no new assets were found
+      if (newAssetsCount == 0) {
+        _log.fine(
+          "No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}",
+        );
+        return false;
+      }
+
+      // Check whether there is only addition or if there has been deletions
+      if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssetsCount) {
+        _log.fine("Local album has modifications. Proceeding to full sync");
+        return false;
+      }
+
+      final newAssets = await _nativeSyncApi.getAssetsForAlbum(
+        deviceAlbum.id,
+        updatedTimeCond: updatedTime,
+      );
+
+      await _localAlbumRepository.upsert(
+        deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
+        toUpsert: newAssets.toLocalAssets(),
+      );
+
+      return true;
+    } catch (e, s) {
+      _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
+    }
+    return false;
+  }
+
+  @visibleForTesting
+  // The [deviceAlbum] is expected to be refreshed before calling this method
+  // with modified time and asset count
+  Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
+    try {
+      final assetsInDevice = deviceAlbum.assetCount > 0
+          ? await _nativeSyncApi
+              .getAssetsForAlbum(deviceAlbum.id)
+              .then((a) => a.toLocalAssets())
+          : <LocalAsset>[];
+      final assetsInDb = dbAlbum.assetCount > 0
+          ? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id)
+          : <LocalAsset>[];
+
+      if (deviceAlbum.assetCount == 0) {
+        _log.fine(
+          "Device album ${deviceAlbum.name} is empty. Removing assets from DB.",
+        );
+        await _localAlbumRepository.upsert(
+          deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
+          toDelete: assetsInDb.map((a) => a.id),
+        );
+        return true;
+      }
+
+      final updatedDeviceAlbum = deviceAlbum.copyWith(
+        backupSelection: dbAlbum.backupSelection,
+      );
+
+      if (dbAlbum.assetCount == 0) {
+        _log.fine(
+          "Device album ${deviceAlbum.name} is empty. Adding assets to DB.",
+        );
+        await _localAlbumRepository.upsert(
+          updatedDeviceAlbum,
+          toUpsert: assetsInDevice,
+        );
+        return true;
+      }
+
+      assert(assetsInDb.isSortedBy((a) => a.id));
+      assetsInDevice.sort((a, b) => a.id.compareTo(b.id));
+
+      final assetsToUpsert = <LocalAsset>[];
+      final assetsToDelete = <String>[];
+
+      diffSortedListsSync(
+        assetsInDb,
+        assetsInDevice,
+        compare: (a, b) => a.id.compareTo(b.id),
+        both: (dbAsset, deviceAsset) {
+          // Custom comparison to check if the asset has been modified without
+          // comparing the checksum
+          if (!_assetsEqual(dbAsset, deviceAsset)) {
+            assetsToUpsert.add(deviceAsset);
+            return true;
+          }
+          return false;
+        },
+        onlyFirst: (dbAsset) => assetsToDelete.add(dbAsset.id),
+        onlySecond: (deviceAsset) => assetsToUpsert.add(deviceAsset),
+      );
+
+      _log.fine(
+        "Syncing ${deviceAlbum.name}. ${assetsToUpsert.length} assets to add/update and ${assetsToDelete.length} assets to delete",
+      );
+
+      if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) {
+        _log.fine(
+          "No asset changes detected in album ${deviceAlbum.name}. Updating metadata.",
+        );
+        _localAlbumRepository.upsert(updatedDeviceAlbum);
+        return true;
+      }
+
+      await _localAlbumRepository.upsert(
+        updatedDeviceAlbum,
+        toUpsert: assetsToUpsert,
+        toDelete: assetsToDelete,
+      );
+
+      return true;
+    } catch (e, s) {
+      _log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s);
+    }
+    return true;
+  }
+
+  bool _assetsEqual(LocalAsset a, LocalAsset b) {
+    return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
+        a.createdAt.isAtSameMomentAs(b.createdAt) &&
+        a.width == b.width &&
+        a.height == b.height &&
+        a.durationInSeconds == b.durationInSeconds;
+  }
+
+  bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
+    return a.name == b.name &&
+        a.assetCount == b.assetCount &&
+        a.updatedAt.isAtSameMomentAs(b.updatedAt);
+  }
+}
+
+extension on Iterable<PlatformAlbum> {
+  List<LocalAlbum> toLocalAlbums() {
+    return map(
+      (e) => LocalAlbum(
+        id: e.id,
+        name: e.name,
+        updatedAt: e.updatedAt == null
+            ? DateTime.now()
+            : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
+        assetCount: e.assetCount,
+      ),
+    ).toList();
+  }
+}
+
+extension on Iterable<PlatformAsset> {
+  List<LocalAsset> toLocalAssets() {
+    return map(
+      (e) => LocalAsset(
+        id: e.id,
+        name: e.name,
+        type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
+        createdAt: e.createdAt == null
+            ? DateTime.now()
+            : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
+        updatedAt: e.updatedAt == null
+            ? DateTime.now()
+            : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
+        durationInSeconds: e.durationInSeconds,
+      ),
+    ).toList();
+  }
+}
diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart
index f63dc81ba9..6a694ee44a 100644
--- a/mobile/lib/domain/utils/background_sync.dart
+++ b/mobile/lib/domain/utils/background_sync.dart
@@ -1,13 +1,12 @@
-// ignore_for_file: avoid-passing-async-when-sync-expected
-
 import 'dart:async';
 
-import 'package:immich_mobile/providers/infrastructure/sync_stream.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
 import 'package:immich_mobile/utils/isolate.dart';
 import 'package:worker_manager/worker_manager.dart';
 
 class BackgroundSyncManager {
   Cancelable<void>? _syncTask;
+  Cancelable<void>? _deviceAlbumSyncTask;
 
   BackgroundSyncManager();
 
@@ -23,7 +22,30 @@ class BackgroundSyncManager {
     return Future.wait(futures);
   }
 
-  Future<void> sync() {
+  // No need to cancel the task, as it can also be run when the user logs out
+  Future<void> syncLocal({bool full = false}) {
+    if (_deviceAlbumSyncTask != null) {
+      return _deviceAlbumSyncTask!.future;
+    }
+
+    // We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
+    // captured by the closure passed to [runInIsolateGentle].
+    _deviceAlbumSyncTask = full
+        ? runInIsolateGentle(
+            computation: (ref) =>
+                ref.read(localSyncServiceProvider).sync(full: true),
+          )
+        : runInIsolateGentle(
+            computation: (ref) =>
+                ref.read(localSyncServiceProvider).sync(full: false),
+          );
+
+    return _deviceAlbumSyncTask!.whenComplete(() {
+      _deviceAlbumSyncTask = null;
+    });
+  }
+
+  Future<void> syncRemote() {
     if (_syncTask != null) {
       return _syncTask!.future;
     }
@@ -31,9 +53,8 @@ class BackgroundSyncManager {
     _syncTask = runInIsolateGentle(
       computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
     );
-    _syncTask!.whenComplete(() {
+    return _syncTask!.whenComplete(() {
       _syncTask = null;
     });
-    return _syncTask!.future;
   }
 }
diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart
new file mode 100644
index 0000000000..74c3e7a8f7
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_album.entity.dart
@@ -0,0 +1,18 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/domain/models/local_album.model.dart';
+import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
+
+class LocalAlbumEntity extends Table with DriftDefaultsMixin {
+  const LocalAlbumEntity();
+
+  TextColumn get id => text()();
+  TextColumn get name => text()();
+  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
+  IntColumn get backupSelection => intEnum<BackupSelection>()();
+
+  // Used for mark & sweep
+  BoolColumn get marker_ => boolean().nullable()();
+
+  @override
+  Set<Column> get primaryKey => {id};
+}
diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart
new file mode 100644
index 0000000000..5955742ec0
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart
@@ -0,0 +1,497 @@
+// dart format width=80
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart' as i0;
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
+    as i1;
+import 'package:immich_mobile/domain/models/local_album.model.dart' as i2;
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
+    as i3;
+import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
+
+typedef $$LocalAlbumEntityTableCreateCompanionBuilder
+    = i1.LocalAlbumEntityCompanion Function({
+  required String id,
+  required String name,
+  i0.Value<DateTime> updatedAt,
+  required i2.BackupSelection backupSelection,
+  i0.Value<bool?> marker_,
+});
+typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
+    = i1.LocalAlbumEntityCompanion Function({
+  i0.Value<String> id,
+  i0.Value<String> name,
+  i0.Value<DateTime> updatedAt,
+  i0.Value<i2.BackupSelection> backupSelection,
+  i0.Value<bool?> marker_,
+});
+
+class $$LocalAlbumEntityTableFilterComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
+  $$LocalAlbumEntityTableFilterComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.ColumnFilters<String> get id => $composableBuilder(
+      column: $table.id, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<String> get name => $composableBuilder(
+      column: $table.name, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
+      column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnWithTypeConverterFilters<i2.BackupSelection, i2.BackupSelection, int>
+      get backupSelection => $composableBuilder(
+          column: $table.backupSelection,
+          builder: (column) => i0.ColumnWithTypeConverterFilters(column));
+
+  i0.ColumnFilters<bool> get marker_ => $composableBuilder(
+      column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
+}
+
+class $$LocalAlbumEntityTableOrderingComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
+  $$LocalAlbumEntityTableOrderingComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.ColumnOrderings<String> get id => $composableBuilder(
+      column: $table.id, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<String> get name => $composableBuilder(
+      column: $table.name, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
+      column: $table.updatedAt,
+      builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<int> get backupSelection => $composableBuilder(
+      column: $table.backupSelection,
+      builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
+      column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
+}
+
+class $$LocalAlbumEntityTableAnnotationComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
+  $$LocalAlbumEntityTableAnnotationComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.GeneratedColumn<String> get id =>
+      $composableBuilder(column: $table.id, builder: (column) => column);
+
+  i0.GeneratedColumn<String> get name =>
+      $composableBuilder(column: $table.name, builder: (column) => column);
+
+  i0.GeneratedColumn<DateTime> get updatedAt =>
+      $composableBuilder(column: $table.updatedAt, builder: (column) => column);
+
+  i0.GeneratedColumnWithTypeConverter<i2.BackupSelection, int>
+      get backupSelection => $composableBuilder(
+          column: $table.backupSelection, builder: (column) => column);
+
+  i0.GeneratedColumn<bool> get marker_ =>
+      $composableBuilder(column: $table.marker_, builder: (column) => column);
+}
+
+class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
+    i0.GeneratedDatabase,
+    i1.$LocalAlbumEntityTable,
+    i1.LocalAlbumEntityData,
+    i1.$$LocalAlbumEntityTableFilterComposer,
+    i1.$$LocalAlbumEntityTableOrderingComposer,
+    i1.$$LocalAlbumEntityTableAnnotationComposer,
+    $$LocalAlbumEntityTableCreateCompanionBuilder,
+    $$LocalAlbumEntityTableUpdateCompanionBuilder,
+    (
+      i1.LocalAlbumEntityData,
+      i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable,
+          i1.LocalAlbumEntityData>
+    ),
+    i1.LocalAlbumEntityData,
+    i0.PrefetchHooks Function()> {
+  $$LocalAlbumEntityTableTableManager(
+      i0.GeneratedDatabase db, i1.$LocalAlbumEntityTable table)
+      : super(i0.TableManagerState(
+          db: db,
+          table: table,
+          createFilteringComposer: () =>
+              i1.$$LocalAlbumEntityTableFilterComposer($db: db, $table: table),
+          createOrderingComposer: () => i1
+              .$$LocalAlbumEntityTableOrderingComposer($db: db, $table: table),
+          createComputedFieldComposer: () =>
+              i1.$$LocalAlbumEntityTableAnnotationComposer(
+                  $db: db, $table: table),
+          updateCompanionCallback: ({
+            i0.Value<String> id = const i0.Value.absent(),
+            i0.Value<String> name = const i0.Value.absent(),
+            i0.Value<DateTime> updatedAt = const i0.Value.absent(),
+            i0.Value<i2.BackupSelection> backupSelection =
+                const i0.Value.absent(),
+            i0.Value<bool?> marker_ = const i0.Value.absent(),
+          }) =>
+              i1.LocalAlbumEntityCompanion(
+            id: id,
+            name: name,
+            updatedAt: updatedAt,
+            backupSelection: backupSelection,
+            marker_: marker_,
+          ),
+          createCompanionCallback: ({
+            required String id,
+            required String name,
+            i0.Value<DateTime> updatedAt = const i0.Value.absent(),
+            required i2.BackupSelection backupSelection,
+            i0.Value<bool?> marker_ = const i0.Value.absent(),
+          }) =>
+              i1.LocalAlbumEntityCompanion.insert(
+            id: id,
+            name: name,
+            updatedAt: updatedAt,
+            backupSelection: backupSelection,
+            marker_: marker_,
+          ),
+          withReferenceMapper: (p0) => p0
+              .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
+              .toList(),
+          prefetchHooksCallback: null,
+        ));
+}
+
+typedef $$LocalAlbumEntityTableProcessedTableManager = i0.ProcessedTableManager<
+    i0.GeneratedDatabase,
+    i1.$LocalAlbumEntityTable,
+    i1.LocalAlbumEntityData,
+    i1.$$LocalAlbumEntityTableFilterComposer,
+    i1.$$LocalAlbumEntityTableOrderingComposer,
+    i1.$$LocalAlbumEntityTableAnnotationComposer,
+    $$LocalAlbumEntityTableCreateCompanionBuilder,
+    $$LocalAlbumEntityTableUpdateCompanionBuilder,
+    (
+      i1.LocalAlbumEntityData,
+      i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable,
+          i1.LocalAlbumEntityData>
+    ),
+    i1.LocalAlbumEntityData,
+    i0.PrefetchHooks Function()>;
+
+class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
+    with i0.TableInfo<$LocalAlbumEntityTable, i1.LocalAlbumEntityData> {
+  @override
+  final i0.GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  $LocalAlbumEntityTable(this.attachedDatabase, [this._alias]);
+  static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
+  @override
+  late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
+      'id', aliasedName, false,
+      type: i0.DriftSqlType.string, requiredDuringInsert: true);
+  static const i0.VerificationMeta _nameMeta =
+      const i0.VerificationMeta('name');
+  @override
+  late final i0.GeneratedColumn<String> name = i0.GeneratedColumn<String>(
+      'name', aliasedName, false,
+      type: i0.DriftSqlType.string, requiredDuringInsert: true);
+  static const i0.VerificationMeta _updatedAtMeta =
+      const i0.VerificationMeta('updatedAt');
+  @override
+  late final i0.GeneratedColumn<DateTime> updatedAt =
+      i0.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
+          type: i0.DriftSqlType.dateTime,
+          requiredDuringInsert: false,
+          defaultValue: i4.currentDateAndTime);
+  @override
+  late final i0.GeneratedColumnWithTypeConverter<i2.BackupSelection, int>
+      backupSelection = i0.GeneratedColumn<int>(
+              'backup_selection', aliasedName, false,
+              type: i0.DriftSqlType.int, requiredDuringInsert: true)
+          .withConverter<i2.BackupSelection>(
+              i1.$LocalAlbumEntityTable.$converterbackupSelection);
+  static const i0.VerificationMeta _marker_Meta =
+      const i0.VerificationMeta('marker_');
+  @override
+  late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>(
+      'marker', aliasedName, true,
+      type: i0.DriftSqlType.bool,
+      requiredDuringInsert: false,
+      defaultConstraints:
+          i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'));
+  @override
+  List<i0.GeneratedColumn> get $columns =>
+      [id, name, updatedAt, backupSelection, marker_];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'local_album_entity';
+  @override
+  i0.VerificationContext validateIntegrity(
+      i0.Insertable<i1.LocalAlbumEntityData> instance,
+      {bool isInserting = false}) {
+    final context = i0.VerificationContext();
+    final data = instance.toColumns(true);
+    if (data.containsKey('id')) {
+      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
+    } else if (isInserting) {
+      context.missing(_idMeta);
+    }
+    if (data.containsKey('name')) {
+      context.handle(
+          _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
+    } else if (isInserting) {
+      context.missing(_nameMeta);
+    }
+    if (data.containsKey('updated_at')) {
+      context.handle(_updatedAtMeta,
+          updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
+    }
+    if (data.containsKey('marker')) {
+      context.handle(_marker_Meta,
+          marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
+    }
+    return context;
+  }
+
+  @override
+  Set<i0.GeneratedColumn> get $primaryKey => {id};
+  @override
+  i1.LocalAlbumEntityData map(Map<String, dynamic> data,
+      {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return i1.LocalAlbumEntityData(
+      id: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!,
+      name: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
+      updatedAt: attachedDatabase.typeMapping.read(
+          i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
+      backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
+          .fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
+              data['${effectivePrefix}backup_selection'])!),
+      marker_: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']),
+    );
+  }
+
+  @override
+  $LocalAlbumEntityTable createAlias(String alias) {
+    return $LocalAlbumEntityTable(attachedDatabase, alias);
+  }
+
+  static i0.JsonTypeConverter2<i2.BackupSelection, int, int>
+      $converterbackupSelection =
+      const i0.EnumIndexConverter<i2.BackupSelection>(
+          i2.BackupSelection.values);
+  @override
+  bool get withoutRowId => true;
+  @override
+  bool get isStrict => true;
+}
+
+class LocalAlbumEntityData extends i0.DataClass
+    implements i0.Insertable<i1.LocalAlbumEntityData> {
+  final String id;
+  final String name;
+  final DateTime updatedAt;
+  final i2.BackupSelection backupSelection;
+  final bool? marker_;
+  const LocalAlbumEntityData(
+      {required this.id,
+      required this.name,
+      required this.updatedAt,
+      required this.backupSelection,
+      this.marker_});
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    map['id'] = i0.Variable<String>(id);
+    map['name'] = i0.Variable<String>(name);
+    map['updated_at'] = i0.Variable<DateTime>(updatedAt);
+    {
+      map['backup_selection'] = i0.Variable<int>(i1
+          .$LocalAlbumEntityTable.$converterbackupSelection
+          .toSql(backupSelection));
+    }
+    if (!nullToAbsent || marker_ != null) {
+      map['marker'] = i0.Variable<bool>(marker_);
+    }
+    return map;
+  }
+
+  factory LocalAlbumEntityData.fromJson(Map<String, dynamic> json,
+      {i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return LocalAlbumEntityData(
+      id: serializer.fromJson<String>(json['id']),
+      name: serializer.fromJson<String>(json['name']),
+      updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
+      backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
+          .fromJson(serializer.fromJson<int>(json['backupSelection'])),
+      marker_: serializer.fromJson<bool?>(json['marker_']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'id': serializer.toJson<String>(id),
+      'name': serializer.toJson<String>(name),
+      'updatedAt': serializer.toJson<DateTime>(updatedAt),
+      'backupSelection': serializer.toJson<int>(i1
+          .$LocalAlbumEntityTable.$converterbackupSelection
+          .toJson(backupSelection)),
+      'marker_': serializer.toJson<bool?>(marker_),
+    };
+  }
+
+  i1.LocalAlbumEntityData copyWith(
+          {String? id,
+          String? name,
+          DateTime? updatedAt,
+          i2.BackupSelection? backupSelection,
+          i0.Value<bool?> marker_ = const i0.Value.absent()}) =>
+      i1.LocalAlbumEntityData(
+        id: id ?? this.id,
+        name: name ?? this.name,
+        updatedAt: updatedAt ?? this.updatedAt,
+        backupSelection: backupSelection ?? this.backupSelection,
+        marker_: marker_.present ? marker_.value : this.marker_,
+      );
+  LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
+    return LocalAlbumEntityData(
+      id: data.id.present ? data.id.value : this.id,
+      name: data.name.present ? data.name.value : this.name,
+      updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
+      backupSelection: data.backupSelection.present
+          ? data.backupSelection.value
+          : this.backupSelection,
+      marker_: data.marker_.present ? data.marker_.value : this.marker_,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAlbumEntityData(')
+          ..write('id: $id, ')
+          ..write('name: $name, ')
+          ..write('updatedAt: $updatedAt, ')
+          ..write('backupSelection: $backupSelection, ')
+          ..write('marker_: $marker_')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode =>
+      Object.hash(id, name, updatedAt, backupSelection, marker_);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is i1.LocalAlbumEntityData &&
+          other.id == this.id &&
+          other.name == this.name &&
+          other.updatedAt == this.updatedAt &&
+          other.backupSelection == this.backupSelection &&
+          other.marker_ == this.marker_);
+}
+
+class LocalAlbumEntityCompanion
+    extends i0.UpdateCompanion<i1.LocalAlbumEntityData> {
+  final i0.Value<String> id;
+  final i0.Value<String> name;
+  final i0.Value<DateTime> updatedAt;
+  final i0.Value<i2.BackupSelection> backupSelection;
+  final i0.Value<bool?> marker_;
+  const LocalAlbumEntityCompanion({
+    this.id = const i0.Value.absent(),
+    this.name = const i0.Value.absent(),
+    this.updatedAt = const i0.Value.absent(),
+    this.backupSelection = const i0.Value.absent(),
+    this.marker_ = const i0.Value.absent(),
+  });
+  LocalAlbumEntityCompanion.insert({
+    required String id,
+    required String name,
+    this.updatedAt = const i0.Value.absent(),
+    required i2.BackupSelection backupSelection,
+    this.marker_ = const i0.Value.absent(),
+  })  : id = i0.Value(id),
+        name = i0.Value(name),
+        backupSelection = i0.Value(backupSelection);
+  static i0.Insertable<i1.LocalAlbumEntityData> custom({
+    i0.Expression<String>? id,
+    i0.Expression<String>? name,
+    i0.Expression<DateTime>? updatedAt,
+    i0.Expression<int>? backupSelection,
+    i0.Expression<bool>? marker_,
+  }) {
+    return i0.RawValuesInsertable({
+      if (id != null) 'id': id,
+      if (name != null) 'name': name,
+      if (updatedAt != null) 'updated_at': updatedAt,
+      if (backupSelection != null) 'backup_selection': backupSelection,
+      if (marker_ != null) 'marker': marker_,
+    });
+  }
+
+  i1.LocalAlbumEntityCompanion copyWith(
+      {i0.Value<String>? id,
+      i0.Value<String>? name,
+      i0.Value<DateTime>? updatedAt,
+      i0.Value<i2.BackupSelection>? backupSelection,
+      i0.Value<bool?>? marker_}) {
+    return i1.LocalAlbumEntityCompanion(
+      id: id ?? this.id,
+      name: name ?? this.name,
+      updatedAt: updatedAt ?? this.updatedAt,
+      backupSelection: backupSelection ?? this.backupSelection,
+      marker_: marker_ ?? this.marker_,
+    );
+  }
+
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    if (id.present) {
+      map['id'] = i0.Variable<String>(id.value);
+    }
+    if (name.present) {
+      map['name'] = i0.Variable<String>(name.value);
+    }
+    if (updatedAt.present) {
+      map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
+    }
+    if (backupSelection.present) {
+      map['backup_selection'] = i0.Variable<int>(i1
+          .$LocalAlbumEntityTable.$converterbackupSelection
+          .toSql(backupSelection.value));
+    }
+    if (marker_.present) {
+      map['marker'] = i0.Variable<bool>(marker_.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAlbumEntityCompanion(')
+          ..write('id: $id, ')
+          ..write('name: $name, ')
+          ..write('updatedAt: $updatedAt, ')
+          ..write('backupSelection: $backupSelection, ')
+          ..write('marker_: $marker_')
+          ..write(')'))
+        .toString();
+  }
+}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
new file mode 100644
index 0000000000..b64b9ec2fb
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
@@ -0,0 +1,17 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
+import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
+
+class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
+  const LocalAlbumAssetEntity();
+
+  TextColumn get assetId =>
+      text().references(LocalAssetEntity, #id, onDelete: KeyAction.cascade)();
+
+  TextColumn get albumId =>
+      text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
+
+  @override
+  Set<Column> get primaryKey => {assetId, albumId};
+}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
new file mode 100644
index 0000000000..e8f94fa74b
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
@@ -0,0 +1,565 @@
+// dart format width=80
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart' as i0;
+import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
+    as i1;
+import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart'
+    as i2;
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
+    as i3;
+import 'package:drift/internal/modular.dart' as i4;
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
+    as i5;
+
+typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder
+    = i1.LocalAlbumAssetEntityCompanion Function({
+  required String assetId,
+  required String albumId,
+});
+typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder
+    = i1.LocalAlbumAssetEntityCompanion Function({
+  i0.Value<String> assetId,
+  i0.Value<String> albumId,
+});
+
+final class $$LocalAlbumAssetEntityTableReferences extends i0.BaseReferences<
+    i0.GeneratedDatabase,
+    i1.$LocalAlbumAssetEntityTable,
+    i1.LocalAlbumAssetEntityData> {
+  $$LocalAlbumAssetEntityTableReferences(
+      super.$_db, super.$_table, super.$_typedResult);
+
+  static i3.$LocalAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
+      i4.ReadDatabaseContainer(db)
+          .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity')
+          .createAlias(i0.$_aliasNameGenerator(
+              i4.ReadDatabaseContainer(db)
+                  .resultSet<i1.$LocalAlbumAssetEntityTable>(
+                      'local_album_asset_entity')
+                  .assetId,
+              i4.ReadDatabaseContainer(db)
+                  .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity')
+                  .id));
+
+  i3.$$LocalAssetEntityTableProcessedTableManager get assetId {
+    final $_column = $_itemColumn<String>('asset_id')!;
+
+    final manager = i3
+        .$$LocalAssetEntityTableTableManager(
+            $_db,
+            i4.ReadDatabaseContainer($_db)
+                .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity'))
+        .filter((f) => f.id.sqlEquals($_column));
+    final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
+    if (item == null) return manager;
+    return i0.ProcessedTableManager(
+        manager.$state.copyWith(prefetchedData: [item]));
+  }
+
+  static i5.$LocalAlbumEntityTable _albumIdTable(i0.GeneratedDatabase db) =>
+      i4.ReadDatabaseContainer(db)
+          .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity')
+          .createAlias(i0.$_aliasNameGenerator(
+              i4.ReadDatabaseContainer(db)
+                  .resultSet<i1.$LocalAlbumAssetEntityTable>(
+                      'local_album_asset_entity')
+                  .albumId,
+              i4.ReadDatabaseContainer(db)
+                  .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity')
+                  .id));
+
+  i5.$$LocalAlbumEntityTableProcessedTableManager get albumId {
+    final $_column = $_itemColumn<String>('album_id')!;
+
+    final manager = i5
+        .$$LocalAlbumEntityTableTableManager(
+            $_db,
+            i4.ReadDatabaseContainer($_db)
+                .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity'))
+        .filter((f) => f.id.sqlEquals($_column));
+    final item = $_typedResult.readTableOrNull(_albumIdTable($_db));
+    if (item == null) return manager;
+    return i0.ProcessedTableManager(
+        manager.$state.copyWith(prefetchedData: [item]));
+  }
+}
+
+class $$LocalAlbumAssetEntityTableFilterComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumAssetEntityTable> {
+  $$LocalAlbumAssetEntityTableFilterComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i3.$$LocalAssetEntityTableFilterComposer get assetId {
+    final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
+        composer: this,
+        getCurrentColumn: (t) => t.assetId,
+        referencedTable: i4.ReadDatabaseContainer($db)
+            .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity'),
+        getReferencedColumn: (t) => t.id,
+        builder: (joinBuilder,
+                {$addJoinBuilderToRootComposer,
+                $removeJoinBuilderFromRootComposer}) =>
+            i3.$$LocalAssetEntityTableFilterComposer(
+              $db: $db,
+              $table: i4.ReadDatabaseContainer($db)
+                  .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity'),
+              $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+              joinBuilder: joinBuilder,
+              $removeJoinBuilderFromRootComposer:
+                  $removeJoinBuilderFromRootComposer,
+            ));
+    return composer;
+  }
+
+  i5.$$LocalAlbumEntityTableFilterComposer get albumId {
+    final i5.$$LocalAlbumEntityTableFilterComposer composer = $composerBuilder(
+        composer: this,
+        getCurrentColumn: (t) => t.albumId,
+        referencedTable: i4.ReadDatabaseContainer($db)
+            .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity'),
+        getReferencedColumn: (t) => t.id,
+        builder: (joinBuilder,
+                {$addJoinBuilderToRootComposer,
+                $removeJoinBuilderFromRootComposer}) =>
+            i5.$$LocalAlbumEntityTableFilterComposer(
+              $db: $db,
+              $table: i4.ReadDatabaseContainer($db)
+                  .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity'),
+              $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+              joinBuilder: joinBuilder,
+              $removeJoinBuilderFromRootComposer:
+                  $removeJoinBuilderFromRootComposer,
+            ));
+    return composer;
+  }
+}
+
+class $$LocalAlbumAssetEntityTableOrderingComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumAssetEntityTable> {
+  $$LocalAlbumAssetEntityTableOrderingComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i3.$$LocalAssetEntityTableOrderingComposer get assetId {
+    final i3.$$LocalAssetEntityTableOrderingComposer composer =
+        $composerBuilder(
+            composer: this,
+            getCurrentColumn: (t) => t.assetId,
+            referencedTable: i4.ReadDatabaseContainer($db)
+                .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity'),
+            getReferencedColumn: (t) => t.id,
+            builder: (joinBuilder,
+                    {$addJoinBuilderToRootComposer,
+                    $removeJoinBuilderFromRootComposer}) =>
+                i3.$$LocalAssetEntityTableOrderingComposer(
+                  $db: $db,
+                  $table: i4.ReadDatabaseContainer($db)
+                      .resultSet<i3.$LocalAssetEntityTable>(
+                          'local_asset_entity'),
+                  $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+                  joinBuilder: joinBuilder,
+                  $removeJoinBuilderFromRootComposer:
+                      $removeJoinBuilderFromRootComposer,
+                ));
+    return composer;
+  }
+
+  i5.$$LocalAlbumEntityTableOrderingComposer get albumId {
+    final i5.$$LocalAlbumEntityTableOrderingComposer composer =
+        $composerBuilder(
+            composer: this,
+            getCurrentColumn: (t) => t.albumId,
+            referencedTable: i4.ReadDatabaseContainer($db)
+                .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity'),
+            getReferencedColumn: (t) => t.id,
+            builder: (joinBuilder,
+                    {$addJoinBuilderToRootComposer,
+                    $removeJoinBuilderFromRootComposer}) =>
+                i5.$$LocalAlbumEntityTableOrderingComposer(
+                  $db: $db,
+                  $table: i4.ReadDatabaseContainer($db)
+                      .resultSet<i5.$LocalAlbumEntityTable>(
+                          'local_album_entity'),
+                  $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+                  joinBuilder: joinBuilder,
+                  $removeJoinBuilderFromRootComposer:
+                      $removeJoinBuilderFromRootComposer,
+                ));
+    return composer;
+  }
+}
+
+class $$LocalAlbumAssetEntityTableAnnotationComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumAssetEntityTable> {
+  $$LocalAlbumAssetEntityTableAnnotationComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
+    final i3.$$LocalAssetEntityTableAnnotationComposer composer =
+        $composerBuilder(
+            composer: this,
+            getCurrentColumn: (t) => t.assetId,
+            referencedTable: i4.ReadDatabaseContainer($db)
+                .resultSet<i3.$LocalAssetEntityTable>('local_asset_entity'),
+            getReferencedColumn: (t) => t.id,
+            builder: (joinBuilder,
+                    {$addJoinBuilderToRootComposer,
+                    $removeJoinBuilderFromRootComposer}) =>
+                i3.$$LocalAssetEntityTableAnnotationComposer(
+                  $db: $db,
+                  $table: i4.ReadDatabaseContainer($db)
+                      .resultSet<i3.$LocalAssetEntityTable>(
+                          'local_asset_entity'),
+                  $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+                  joinBuilder: joinBuilder,
+                  $removeJoinBuilderFromRootComposer:
+                      $removeJoinBuilderFromRootComposer,
+                ));
+    return composer;
+  }
+
+  i5.$$LocalAlbumEntityTableAnnotationComposer get albumId {
+    final i5.$$LocalAlbumEntityTableAnnotationComposer composer =
+        $composerBuilder(
+            composer: this,
+            getCurrentColumn: (t) => t.albumId,
+            referencedTable: i4.ReadDatabaseContainer($db)
+                .resultSet<i5.$LocalAlbumEntityTable>('local_album_entity'),
+            getReferencedColumn: (t) => t.id,
+            builder: (joinBuilder,
+                    {$addJoinBuilderToRootComposer,
+                    $removeJoinBuilderFromRootComposer}) =>
+                i5.$$LocalAlbumEntityTableAnnotationComposer(
+                  $db: $db,
+                  $table: i4.ReadDatabaseContainer($db)
+                      .resultSet<i5.$LocalAlbumEntityTable>(
+                          'local_album_entity'),
+                  $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
+                  joinBuilder: joinBuilder,
+                  $removeJoinBuilderFromRootComposer:
+                      $removeJoinBuilderFromRootComposer,
+                ));
+    return composer;
+  }
+}
+
+class $$LocalAlbumAssetEntityTableTableManager extends i0.RootTableManager<
+    i0.GeneratedDatabase,
+    i1.$LocalAlbumAssetEntityTable,
+    i1.LocalAlbumAssetEntityData,
+    i1.$$LocalAlbumAssetEntityTableFilterComposer,
+    i1.$$LocalAlbumAssetEntityTableOrderingComposer,
+    i1.$$LocalAlbumAssetEntityTableAnnotationComposer,
+    $$LocalAlbumAssetEntityTableCreateCompanionBuilder,
+    $$LocalAlbumAssetEntityTableUpdateCompanionBuilder,
+    (i1.LocalAlbumAssetEntityData, i1.$$LocalAlbumAssetEntityTableReferences),
+    i1.LocalAlbumAssetEntityData,
+    i0.PrefetchHooks Function({bool assetId, bool albumId})> {
+  $$LocalAlbumAssetEntityTableTableManager(
+      i0.GeneratedDatabase db, i1.$LocalAlbumAssetEntityTable table)
+      : super(i0.TableManagerState(
+          db: db,
+          table: table,
+          createFilteringComposer: () =>
+              i1.$$LocalAlbumAssetEntityTableFilterComposer(
+                  $db: db, $table: table),
+          createOrderingComposer: () =>
+              i1.$$LocalAlbumAssetEntityTableOrderingComposer(
+                  $db: db, $table: table),
+          createComputedFieldComposer: () =>
+              i1.$$LocalAlbumAssetEntityTableAnnotationComposer(
+                  $db: db, $table: table),
+          updateCompanionCallback: ({
+            i0.Value<String> assetId = const i0.Value.absent(),
+            i0.Value<String> albumId = const i0.Value.absent(),
+          }) =>
+              i1.LocalAlbumAssetEntityCompanion(
+            assetId: assetId,
+            albumId: albumId,
+          ),
+          createCompanionCallback: ({
+            required String assetId,
+            required String albumId,
+          }) =>
+              i1.LocalAlbumAssetEntityCompanion.insert(
+            assetId: assetId,
+            albumId: albumId,
+          ),
+          withReferenceMapper: (p0) => p0
+              .map((e) => (
+                    e.readTable(table),
+                    i1.$$LocalAlbumAssetEntityTableReferences(db, table, e)
+                  ))
+              .toList(),
+          prefetchHooksCallback: ({assetId = false, albumId = false}) {
+            return i0.PrefetchHooks(
+              db: db,
+              explicitlyWatchedTables: [],
+              addJoins: <
+                  T extends i0.TableManagerState<
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic,
+                      dynamic>>(state) {
+                if (assetId) {
+                  state = state.withJoin(
+                    currentTable: table,
+                    currentColumn: table.assetId,
+                    referencedTable: i1.$$LocalAlbumAssetEntityTableReferences
+                        ._assetIdTable(db),
+                    referencedColumn: i1.$$LocalAlbumAssetEntityTableReferences
+                        ._assetIdTable(db)
+                        .id,
+                  ) as T;
+                }
+                if (albumId) {
+                  state = state.withJoin(
+                    currentTable: table,
+                    currentColumn: table.albumId,
+                    referencedTable: i1.$$LocalAlbumAssetEntityTableReferences
+                        ._albumIdTable(db),
+                    referencedColumn: i1.$$LocalAlbumAssetEntityTableReferences
+                        ._albumIdTable(db)
+                        .id,
+                  ) as T;
+                }
+
+                return state;
+              },
+              getPrefetchedDataCallback: (items) async {
+                return [];
+              },
+            );
+          },
+        ));
+}
+
+typedef $$LocalAlbumAssetEntityTableProcessedTableManager
+    = i0.ProcessedTableManager<
+        i0.GeneratedDatabase,
+        i1.$LocalAlbumAssetEntityTable,
+        i1.LocalAlbumAssetEntityData,
+        i1.$$LocalAlbumAssetEntityTableFilterComposer,
+        i1.$$LocalAlbumAssetEntityTableOrderingComposer,
+        i1.$$LocalAlbumAssetEntityTableAnnotationComposer,
+        $$LocalAlbumAssetEntityTableCreateCompanionBuilder,
+        $$LocalAlbumAssetEntityTableUpdateCompanionBuilder,
+        (
+          i1.LocalAlbumAssetEntityData,
+          i1.$$LocalAlbumAssetEntityTableReferences
+        ),
+        i1.LocalAlbumAssetEntityData,
+        i0.PrefetchHooks Function({bool assetId, bool albumId})>;
+
+class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
+    with
+        i0
+        .TableInfo<$LocalAlbumAssetEntityTable, i1.LocalAlbumAssetEntityData> {
+  @override
+  final i0.GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  $LocalAlbumAssetEntityTable(this.attachedDatabase, [this._alias]);
+  static const i0.VerificationMeta _assetIdMeta =
+      const i0.VerificationMeta('assetId');
+  @override
+  late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
+      'asset_id', aliasedName, false,
+      type: i0.DriftSqlType.string,
+      requiredDuringInsert: true,
+      defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+          'REFERENCES local_asset_entity (id) ON DELETE CASCADE'));
+  static const i0.VerificationMeta _albumIdMeta =
+      const i0.VerificationMeta('albumId');
+  @override
+  late final i0.GeneratedColumn<String> albumId = i0.GeneratedColumn<String>(
+      'album_id', aliasedName, false,
+      type: i0.DriftSqlType.string,
+      requiredDuringInsert: true,
+      defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+          'REFERENCES local_album_entity (id) ON DELETE CASCADE'));
+  @override
+  List<i0.GeneratedColumn> get $columns => [assetId, albumId];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'local_album_asset_entity';
+  @override
+  i0.VerificationContext validateIntegrity(
+      i0.Insertable<i1.LocalAlbumAssetEntityData> instance,
+      {bool isInserting = false}) {
+    final context = i0.VerificationContext();
+    final data = instance.toColumns(true);
+    if (data.containsKey('asset_id')) {
+      context.handle(_assetIdMeta,
+          assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta));
+    } else if (isInserting) {
+      context.missing(_assetIdMeta);
+    }
+    if (data.containsKey('album_id')) {
+      context.handle(_albumIdMeta,
+          albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta));
+    } else if (isInserting) {
+      context.missing(_albumIdMeta);
+    }
+    return context;
+  }
+
+  @override
+  Set<i0.GeneratedColumn> get $primaryKey => {assetId, albumId};
+  @override
+  i1.LocalAlbumAssetEntityData map(Map<String, dynamic> data,
+      {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return i1.LocalAlbumAssetEntityData(
+      assetId: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}asset_id'])!,
+      albumId: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}album_id'])!,
+    );
+  }
+
+  @override
+  $LocalAlbumAssetEntityTable createAlias(String alias) {
+    return $LocalAlbumAssetEntityTable(attachedDatabase, alias);
+  }
+
+  @override
+  bool get withoutRowId => true;
+  @override
+  bool get isStrict => true;
+}
+
+class LocalAlbumAssetEntityData extends i0.DataClass
+    implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
+  final String assetId;
+  final String albumId;
+  const LocalAlbumAssetEntityData(
+      {required this.assetId, required this.albumId});
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    map['asset_id'] = i0.Variable<String>(assetId);
+    map['album_id'] = i0.Variable<String>(albumId);
+    return map;
+  }
+
+  factory LocalAlbumAssetEntityData.fromJson(Map<String, dynamic> json,
+      {i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return LocalAlbumAssetEntityData(
+      assetId: serializer.fromJson<String>(json['assetId']),
+      albumId: serializer.fromJson<String>(json['albumId']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'assetId': serializer.toJson<String>(assetId),
+      'albumId': serializer.toJson<String>(albumId),
+    };
+  }
+
+  i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
+      i1.LocalAlbumAssetEntityData(
+        assetId: assetId ?? this.assetId,
+        albumId: albumId ?? this.albumId,
+      );
+  LocalAlbumAssetEntityData copyWithCompanion(
+      i1.LocalAlbumAssetEntityCompanion data) {
+    return LocalAlbumAssetEntityData(
+      assetId: data.assetId.present ? data.assetId.value : this.assetId,
+      albumId: data.albumId.present ? data.albumId.value : this.albumId,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAlbumAssetEntityData(')
+          ..write('assetId: $assetId, ')
+          ..write('albumId: $albumId')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(assetId, albumId);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is i1.LocalAlbumAssetEntityData &&
+          other.assetId == this.assetId &&
+          other.albumId == this.albumId);
+}
+
+class LocalAlbumAssetEntityCompanion
+    extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
+  final i0.Value<String> assetId;
+  final i0.Value<String> albumId;
+  const LocalAlbumAssetEntityCompanion({
+    this.assetId = const i0.Value.absent(),
+    this.albumId = const i0.Value.absent(),
+  });
+  LocalAlbumAssetEntityCompanion.insert({
+    required String assetId,
+    required String albumId,
+  })  : assetId = i0.Value(assetId),
+        albumId = i0.Value(albumId);
+  static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
+    i0.Expression<String>? assetId,
+    i0.Expression<String>? albumId,
+  }) {
+    return i0.RawValuesInsertable({
+      if (assetId != null) 'asset_id': assetId,
+      if (albumId != null) 'album_id': albumId,
+    });
+  }
+
+  i1.LocalAlbumAssetEntityCompanion copyWith(
+      {i0.Value<String>? assetId, i0.Value<String>? albumId}) {
+    return i1.LocalAlbumAssetEntityCompanion(
+      assetId: assetId ?? this.assetId,
+      albumId: albumId ?? this.albumId,
+    );
+  }
+
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    if (assetId.present) {
+      map['asset_id'] = i0.Variable<String>(assetId.value);
+    }
+    if (albumId.present) {
+      map['album_id'] = i0.Variable<String>(albumId.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAlbumAssetEntityCompanion(')
+          ..write('assetId: $assetId, ')
+          ..write('albumId: $albumId')
+          ..write(')'))
+        .toString();
+  }
+}
diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart
new file mode 100644
index 0000000000..724cf532c5
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart
@@ -0,0 +1,17 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
+import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
+
+@TableIndex(name: 'local_asset_checksum', columns: {#checksum})
+class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
+  const LocalAssetEntity();
+
+  TextColumn get id => text()();
+  TextColumn get checksum => text().nullable()();
+
+  // Only used during backup to mirror the favorite status of the asset in the server
+  BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
+
+  @override
+  Set<Column> get primaryKey => {id};
+}
diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart
new file mode 100644
index 0000000000..0a4896a4a3
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart
@@ -0,0 +1,658 @@
+// dart format width=80
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart' as i0;
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
+    as i1;
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'
+    as i3;
+import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
+
+typedef $$LocalAssetEntityTableCreateCompanionBuilder
+    = i1.LocalAssetEntityCompanion Function({
+  required String name,
+  required i2.AssetType type,
+  i0.Value<DateTime> createdAt,
+  i0.Value<DateTime> updatedAt,
+  i0.Value<int?> durationInSeconds,
+  required String id,
+  i0.Value<String?> checksum,
+  i0.Value<bool> isFavorite,
+});
+typedef $$LocalAssetEntityTableUpdateCompanionBuilder
+    = i1.LocalAssetEntityCompanion Function({
+  i0.Value<String> name,
+  i0.Value<i2.AssetType> type,
+  i0.Value<DateTime> createdAt,
+  i0.Value<DateTime> updatedAt,
+  i0.Value<int?> durationInSeconds,
+  i0.Value<String> id,
+  i0.Value<String?> checksum,
+  i0.Value<bool> isFavorite,
+});
+
+class $$LocalAssetEntityTableFilterComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAssetEntityTable> {
+  $$LocalAssetEntityTableFilterComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.ColumnFilters<String> get name => $composableBuilder(
+      column: $table.name, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnWithTypeConverterFilters<i2.AssetType, i2.AssetType, int> get type =>
+      $composableBuilder(
+          column: $table.type,
+          builder: (column) => i0.ColumnWithTypeConverterFilters(column));
+
+  i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
+      column: $table.createdAt, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
+      column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<int> get durationInSeconds => $composableBuilder(
+      column: $table.durationInSeconds,
+      builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<String> get id => $composableBuilder(
+      column: $table.id, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<String> get checksum => $composableBuilder(
+      column: $table.checksum, builder: (column) => i0.ColumnFilters(column));
+
+  i0.ColumnFilters<bool> get isFavorite => $composableBuilder(
+      column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column));
+}
+
+class $$LocalAssetEntityTableOrderingComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAssetEntityTable> {
+  $$LocalAssetEntityTableOrderingComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.ColumnOrderings<String> get name => $composableBuilder(
+      column: $table.name, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<int> get type => $composableBuilder(
+      column: $table.type, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
+      column: $table.createdAt,
+      builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
+      column: $table.updatedAt,
+      builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<int> get durationInSeconds => $composableBuilder(
+      column: $table.durationInSeconds,
+      builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<String> get id => $composableBuilder(
+      column: $table.id, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<String> get checksum => $composableBuilder(
+      column: $table.checksum, builder: (column) => i0.ColumnOrderings(column));
+
+  i0.ColumnOrderings<bool> get isFavorite => $composableBuilder(
+      column: $table.isFavorite,
+      builder: (column) => i0.ColumnOrderings(column));
+}
+
+class $$LocalAssetEntityTableAnnotationComposer
+    extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAssetEntityTable> {
+  $$LocalAssetEntityTableAnnotationComposer({
+    required super.$db,
+    required super.$table,
+    super.joinBuilder,
+    super.$addJoinBuilderToRootComposer,
+    super.$removeJoinBuilderFromRootComposer,
+  });
+  i0.GeneratedColumn<String> get name =>
+      $composableBuilder(column: $table.name, builder: (column) => column);
+
+  i0.GeneratedColumnWithTypeConverter<i2.AssetType, int> get type =>
+      $composableBuilder(column: $table.type, builder: (column) => column);
+
+  i0.GeneratedColumn<DateTime> get createdAt =>
+      $composableBuilder(column: $table.createdAt, builder: (column) => column);
+
+  i0.GeneratedColumn<DateTime> get updatedAt =>
+      $composableBuilder(column: $table.updatedAt, builder: (column) => column);
+
+  i0.GeneratedColumn<int> get durationInSeconds => $composableBuilder(
+      column: $table.durationInSeconds, builder: (column) => column);
+
+  i0.GeneratedColumn<String> get id =>
+      $composableBuilder(column: $table.id, builder: (column) => column);
+
+  i0.GeneratedColumn<String> get checksum =>
+      $composableBuilder(column: $table.checksum, builder: (column) => column);
+
+  i0.GeneratedColumn<bool> get isFavorite => $composableBuilder(
+      column: $table.isFavorite, builder: (column) => column);
+}
+
+class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
+    i0.GeneratedDatabase,
+    i1.$LocalAssetEntityTable,
+    i1.LocalAssetEntityData,
+    i1.$$LocalAssetEntityTableFilterComposer,
+    i1.$$LocalAssetEntityTableOrderingComposer,
+    i1.$$LocalAssetEntityTableAnnotationComposer,
+    $$LocalAssetEntityTableCreateCompanionBuilder,
+    $$LocalAssetEntityTableUpdateCompanionBuilder,
+    (
+      i1.LocalAssetEntityData,
+      i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAssetEntityTable,
+          i1.LocalAssetEntityData>
+    ),
+    i1.LocalAssetEntityData,
+    i0.PrefetchHooks Function()> {
+  $$LocalAssetEntityTableTableManager(
+      i0.GeneratedDatabase db, i1.$LocalAssetEntityTable table)
+      : super(i0.TableManagerState(
+          db: db,
+          table: table,
+          createFilteringComposer: () =>
+              i1.$$LocalAssetEntityTableFilterComposer($db: db, $table: table),
+          createOrderingComposer: () => i1
+              .$$LocalAssetEntityTableOrderingComposer($db: db, $table: table),
+          createComputedFieldComposer: () =>
+              i1.$$LocalAssetEntityTableAnnotationComposer(
+                  $db: db, $table: table),
+          updateCompanionCallback: ({
+            i0.Value<String> name = const i0.Value.absent(),
+            i0.Value<i2.AssetType> type = const i0.Value.absent(),
+            i0.Value<DateTime> createdAt = const i0.Value.absent(),
+            i0.Value<DateTime> updatedAt = const i0.Value.absent(),
+            i0.Value<int?> durationInSeconds = const i0.Value.absent(),
+            i0.Value<String> id = const i0.Value.absent(),
+            i0.Value<String?> checksum = const i0.Value.absent(),
+            i0.Value<bool> isFavorite = const i0.Value.absent(),
+          }) =>
+              i1.LocalAssetEntityCompanion(
+            name: name,
+            type: type,
+            createdAt: createdAt,
+            updatedAt: updatedAt,
+            durationInSeconds: durationInSeconds,
+            id: id,
+            checksum: checksum,
+            isFavorite: isFavorite,
+          ),
+          createCompanionCallback: ({
+            required String name,
+            required i2.AssetType type,
+            i0.Value<DateTime> createdAt = const i0.Value.absent(),
+            i0.Value<DateTime> updatedAt = const i0.Value.absent(),
+            i0.Value<int?> durationInSeconds = const i0.Value.absent(),
+            required String id,
+            i0.Value<String?> checksum = const i0.Value.absent(),
+            i0.Value<bool> isFavorite = const i0.Value.absent(),
+          }) =>
+              i1.LocalAssetEntityCompanion.insert(
+            name: name,
+            type: type,
+            createdAt: createdAt,
+            updatedAt: updatedAt,
+            durationInSeconds: durationInSeconds,
+            id: id,
+            checksum: checksum,
+            isFavorite: isFavorite,
+          ),
+          withReferenceMapper: (p0) => p0
+              .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
+              .toList(),
+          prefetchHooksCallback: null,
+        ));
+}
+
+typedef $$LocalAssetEntityTableProcessedTableManager = i0.ProcessedTableManager<
+    i0.GeneratedDatabase,
+    i1.$LocalAssetEntityTable,
+    i1.LocalAssetEntityData,
+    i1.$$LocalAssetEntityTableFilterComposer,
+    i1.$$LocalAssetEntityTableOrderingComposer,
+    i1.$$LocalAssetEntityTableAnnotationComposer,
+    $$LocalAssetEntityTableCreateCompanionBuilder,
+    $$LocalAssetEntityTableUpdateCompanionBuilder,
+    (
+      i1.LocalAssetEntityData,
+      i0.BaseReferences<i0.GeneratedDatabase, i1.$LocalAssetEntityTable,
+          i1.LocalAssetEntityData>
+    ),
+    i1.LocalAssetEntityData,
+    i0.PrefetchHooks Function()>;
+i0.Index get localAssetChecksum => i0.Index('local_asset_checksum',
+    'CREATE INDEX local_asset_checksum ON local_asset_entity (checksum)');
+
+class $LocalAssetEntityTable extends i3.LocalAssetEntity
+    with i0.TableInfo<$LocalAssetEntityTable, i1.LocalAssetEntityData> {
+  @override
+  final i0.GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  $LocalAssetEntityTable(this.attachedDatabase, [this._alias]);
+  static const i0.VerificationMeta _nameMeta =
+      const i0.VerificationMeta('name');
+  @override
+  late final i0.GeneratedColumn<String> name = i0.GeneratedColumn<String>(
+      'name', aliasedName, false,
+      type: i0.DriftSqlType.string, requiredDuringInsert: true);
+  @override
+  late final i0.GeneratedColumnWithTypeConverter<i2.AssetType, int> type =
+      i0.GeneratedColumn<int>('type', aliasedName, false,
+              type: i0.DriftSqlType.int, requiredDuringInsert: true)
+          .withConverter<i2.AssetType>(
+              i1.$LocalAssetEntityTable.$convertertype);
+  static const i0.VerificationMeta _createdAtMeta =
+      const i0.VerificationMeta('createdAt');
+  @override
+  late final i0.GeneratedColumn<DateTime> createdAt =
+      i0.GeneratedColumn<DateTime>('created_at', aliasedName, false,
+          type: i0.DriftSqlType.dateTime,
+          requiredDuringInsert: false,
+          defaultValue: i4.currentDateAndTime);
+  static const i0.VerificationMeta _updatedAtMeta =
+      const i0.VerificationMeta('updatedAt');
+  @override
+  late final i0.GeneratedColumn<DateTime> updatedAt =
+      i0.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
+          type: i0.DriftSqlType.dateTime,
+          requiredDuringInsert: false,
+          defaultValue: i4.currentDateAndTime);
+  static const i0.VerificationMeta _durationInSecondsMeta =
+      const i0.VerificationMeta('durationInSeconds');
+  @override
+  late final i0.GeneratedColumn<int> durationInSeconds =
+      i0.GeneratedColumn<int>('duration_in_seconds', aliasedName, true,
+          type: i0.DriftSqlType.int, requiredDuringInsert: false);
+  static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
+  @override
+  late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
+      'id', aliasedName, false,
+      type: i0.DriftSqlType.string, requiredDuringInsert: true);
+  static const i0.VerificationMeta _checksumMeta =
+      const i0.VerificationMeta('checksum');
+  @override
+  late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
+      'checksum', aliasedName, true,
+      type: i0.DriftSqlType.string, requiredDuringInsert: false);
+  static const i0.VerificationMeta _isFavoriteMeta =
+      const i0.VerificationMeta('isFavorite');
+  @override
+  late final i0.GeneratedColumn<bool> isFavorite = i0.GeneratedColumn<bool>(
+      'is_favorite', aliasedName, false,
+      type: i0.DriftSqlType.bool,
+      requiredDuringInsert: false,
+      defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+          'CHECK ("is_favorite" IN (0, 1))'),
+      defaultValue: const i4.Constant(false));
+  @override
+  List<i0.GeneratedColumn> get $columns => [
+        name,
+        type,
+        createdAt,
+        updatedAt,
+        durationInSeconds,
+        id,
+        checksum,
+        isFavorite
+      ];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'local_asset_entity';
+  @override
+  i0.VerificationContext validateIntegrity(
+      i0.Insertable<i1.LocalAssetEntityData> instance,
+      {bool isInserting = false}) {
+    final context = i0.VerificationContext();
+    final data = instance.toColumns(true);
+    if (data.containsKey('name')) {
+      context.handle(
+          _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
+    } else if (isInserting) {
+      context.missing(_nameMeta);
+    }
+    if (data.containsKey('created_at')) {
+      context.handle(_createdAtMeta,
+          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
+    }
+    if (data.containsKey('updated_at')) {
+      context.handle(_updatedAtMeta,
+          updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
+    }
+    if (data.containsKey('duration_in_seconds')) {
+      context.handle(
+          _durationInSecondsMeta,
+          durationInSeconds.isAcceptableOrUnknown(
+              data['duration_in_seconds']!, _durationInSecondsMeta));
+    }
+    if (data.containsKey('id')) {
+      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
+    } else if (isInserting) {
+      context.missing(_idMeta);
+    }
+    if (data.containsKey('checksum')) {
+      context.handle(_checksumMeta,
+          checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta));
+    }
+    if (data.containsKey('is_favorite')) {
+      context.handle(
+          _isFavoriteMeta,
+          isFavorite.isAcceptableOrUnknown(
+              data['is_favorite']!, _isFavoriteMeta));
+    }
+    return context;
+  }
+
+  @override
+  Set<i0.GeneratedColumn> get $primaryKey => {id};
+  @override
+  i1.LocalAssetEntityData map(Map<String, dynamic> data,
+      {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return i1.LocalAssetEntityData(
+      name: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
+      type: i1.$LocalAssetEntityTable.$convertertype.fromSql(attachedDatabase
+          .typeMapping
+          .read(i0.DriftSqlType.int, data['${effectivePrefix}type'])!),
+      createdAt: attachedDatabase.typeMapping.read(
+          i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
+      updatedAt: attachedDatabase.typeMapping.read(
+          i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
+      durationInSeconds: attachedDatabase.typeMapping.read(
+          i0.DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']),
+      id: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!,
+      checksum: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.string, data['${effectivePrefix}checksum']),
+      isFavorite: attachedDatabase.typeMapping
+          .read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!,
+    );
+  }
+
+  @override
+  $LocalAssetEntityTable createAlias(String alias) {
+    return $LocalAssetEntityTable(attachedDatabase, alias);
+  }
+
+  static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
+      const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
+  @override
+  bool get withoutRowId => true;
+  @override
+  bool get isStrict => true;
+}
+
+class LocalAssetEntityData extends i0.DataClass
+    implements i0.Insertable<i1.LocalAssetEntityData> {
+  final String name;
+  final i2.AssetType type;
+  final DateTime createdAt;
+  final DateTime updatedAt;
+  final int? durationInSeconds;
+  final String id;
+  final String? checksum;
+  final bool isFavorite;
+  const LocalAssetEntityData(
+      {required this.name,
+      required this.type,
+      required this.createdAt,
+      required this.updatedAt,
+      this.durationInSeconds,
+      required this.id,
+      this.checksum,
+      required this.isFavorite});
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    map['name'] = i0.Variable<String>(name);
+    {
+      map['type'] = i0.Variable<int>(
+          i1.$LocalAssetEntityTable.$convertertype.toSql(type));
+    }
+    map['created_at'] = i0.Variable<DateTime>(createdAt);
+    map['updated_at'] = i0.Variable<DateTime>(updatedAt);
+    if (!nullToAbsent || durationInSeconds != null) {
+      map['duration_in_seconds'] = i0.Variable<int>(durationInSeconds);
+    }
+    map['id'] = i0.Variable<String>(id);
+    if (!nullToAbsent || checksum != null) {
+      map['checksum'] = i0.Variable<String>(checksum);
+    }
+    map['is_favorite'] = i0.Variable<bool>(isFavorite);
+    return map;
+  }
+
+  factory LocalAssetEntityData.fromJson(Map<String, dynamic> json,
+      {i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return LocalAssetEntityData(
+      name: serializer.fromJson<String>(json['name']),
+      type: i1.$LocalAssetEntityTable.$convertertype
+          .fromJson(serializer.fromJson<int>(json['type'])),
+      createdAt: serializer.fromJson<DateTime>(json['createdAt']),
+      updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
+      durationInSeconds: serializer.fromJson<int?>(json['durationInSeconds']),
+      id: serializer.fromJson<String>(json['id']),
+      checksum: serializer.fromJson<String?>(json['checksum']),
+      isFavorite: serializer.fromJson<bool>(json['isFavorite']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
+    serializer ??= i0.driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'name': serializer.toJson<String>(name),
+      'type': serializer
+          .toJson<int>(i1.$LocalAssetEntityTable.$convertertype.toJson(type)),
+      'createdAt': serializer.toJson<DateTime>(createdAt),
+      'updatedAt': serializer.toJson<DateTime>(updatedAt),
+      'durationInSeconds': serializer.toJson<int?>(durationInSeconds),
+      'id': serializer.toJson<String>(id),
+      'checksum': serializer.toJson<String?>(checksum),
+      'isFavorite': serializer.toJson<bool>(isFavorite),
+    };
+  }
+
+  i1.LocalAssetEntityData copyWith(
+          {String? name,
+          i2.AssetType? type,
+          DateTime? createdAt,
+          DateTime? updatedAt,
+          i0.Value<int?> durationInSeconds = const i0.Value.absent(),
+          String? id,
+          i0.Value<String?> checksum = const i0.Value.absent(),
+          bool? isFavorite}) =>
+      i1.LocalAssetEntityData(
+        name: name ?? this.name,
+        type: type ?? this.type,
+        createdAt: createdAt ?? this.createdAt,
+        updatedAt: updatedAt ?? this.updatedAt,
+        durationInSeconds: durationInSeconds.present
+            ? durationInSeconds.value
+            : this.durationInSeconds,
+        id: id ?? this.id,
+        checksum: checksum.present ? checksum.value : this.checksum,
+        isFavorite: isFavorite ?? this.isFavorite,
+      );
+  LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
+    return LocalAssetEntityData(
+      name: data.name.present ? data.name.value : this.name,
+      type: data.type.present ? data.type.value : this.type,
+      createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
+      updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
+      durationInSeconds: data.durationInSeconds.present
+          ? data.durationInSeconds.value
+          : this.durationInSeconds,
+      id: data.id.present ? data.id.value : this.id,
+      checksum: data.checksum.present ? data.checksum.value : this.checksum,
+      isFavorite:
+          data.isFavorite.present ? data.isFavorite.value : this.isFavorite,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAssetEntityData(')
+          ..write('name: $name, ')
+          ..write('type: $type, ')
+          ..write('createdAt: $createdAt, ')
+          ..write('updatedAt: $updatedAt, ')
+          ..write('durationInSeconds: $durationInSeconds, ')
+          ..write('id: $id, ')
+          ..write('checksum: $checksum, ')
+          ..write('isFavorite: $isFavorite')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(name, type, createdAt, updatedAt,
+      durationInSeconds, id, checksum, isFavorite);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is i1.LocalAssetEntityData &&
+          other.name == this.name &&
+          other.type == this.type &&
+          other.createdAt == this.createdAt &&
+          other.updatedAt == this.updatedAt &&
+          other.durationInSeconds == this.durationInSeconds &&
+          other.id == this.id &&
+          other.checksum == this.checksum &&
+          other.isFavorite == this.isFavorite);
+}
+
+class LocalAssetEntityCompanion
+    extends i0.UpdateCompanion<i1.LocalAssetEntityData> {
+  final i0.Value<String> name;
+  final i0.Value<i2.AssetType> type;
+  final i0.Value<DateTime> createdAt;
+  final i0.Value<DateTime> updatedAt;
+  final i0.Value<int?> durationInSeconds;
+  final i0.Value<String> id;
+  final i0.Value<String?> checksum;
+  final i0.Value<bool> isFavorite;
+  const LocalAssetEntityCompanion({
+    this.name = const i0.Value.absent(),
+    this.type = const i0.Value.absent(),
+    this.createdAt = const i0.Value.absent(),
+    this.updatedAt = const i0.Value.absent(),
+    this.durationInSeconds = const i0.Value.absent(),
+    this.id = const i0.Value.absent(),
+    this.checksum = const i0.Value.absent(),
+    this.isFavorite = const i0.Value.absent(),
+  });
+  LocalAssetEntityCompanion.insert({
+    required String name,
+    required i2.AssetType type,
+    this.createdAt = const i0.Value.absent(),
+    this.updatedAt = const i0.Value.absent(),
+    this.durationInSeconds = const i0.Value.absent(),
+    required String id,
+    this.checksum = const i0.Value.absent(),
+    this.isFavorite = const i0.Value.absent(),
+  })  : name = i0.Value(name),
+        type = i0.Value(type),
+        id = i0.Value(id);
+  static i0.Insertable<i1.LocalAssetEntityData> custom({
+    i0.Expression<String>? name,
+    i0.Expression<int>? type,
+    i0.Expression<DateTime>? createdAt,
+    i0.Expression<DateTime>? updatedAt,
+    i0.Expression<int>? durationInSeconds,
+    i0.Expression<String>? id,
+    i0.Expression<String>? checksum,
+    i0.Expression<bool>? isFavorite,
+  }) {
+    return i0.RawValuesInsertable({
+      if (name != null) 'name': name,
+      if (type != null) 'type': type,
+      if (createdAt != null) 'created_at': createdAt,
+      if (updatedAt != null) 'updated_at': updatedAt,
+      if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds,
+      if (id != null) 'id': id,
+      if (checksum != null) 'checksum': checksum,
+      if (isFavorite != null) 'is_favorite': isFavorite,
+    });
+  }
+
+  i1.LocalAssetEntityCompanion copyWith(
+      {i0.Value<String>? name,
+      i0.Value<i2.AssetType>? type,
+      i0.Value<DateTime>? createdAt,
+      i0.Value<DateTime>? updatedAt,
+      i0.Value<int?>? durationInSeconds,
+      i0.Value<String>? id,
+      i0.Value<String?>? checksum,
+      i0.Value<bool>? isFavorite}) {
+    return i1.LocalAssetEntityCompanion(
+      name: name ?? this.name,
+      type: type ?? this.type,
+      createdAt: createdAt ?? this.createdAt,
+      updatedAt: updatedAt ?? this.updatedAt,
+      durationInSeconds: durationInSeconds ?? this.durationInSeconds,
+      id: id ?? this.id,
+      checksum: checksum ?? this.checksum,
+      isFavorite: isFavorite ?? this.isFavorite,
+    );
+  }
+
+  @override
+  Map<String, i0.Expression> toColumns(bool nullToAbsent) {
+    final map = <String, i0.Expression>{};
+    if (name.present) {
+      map['name'] = i0.Variable<String>(name.value);
+    }
+    if (type.present) {
+      map['type'] = i0.Variable<int>(
+          i1.$LocalAssetEntityTable.$convertertype.toSql(type.value));
+    }
+    if (createdAt.present) {
+      map['created_at'] = i0.Variable<DateTime>(createdAt.value);
+    }
+    if (updatedAt.present) {
+      map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
+    }
+    if (durationInSeconds.present) {
+      map['duration_in_seconds'] = i0.Variable<int>(durationInSeconds.value);
+    }
+    if (id.present) {
+      map['id'] = i0.Variable<String>(id.value);
+    }
+    if (checksum.present) {
+      map['checksum'] = i0.Variable<String>(checksum.value);
+    }
+    if (isFavorite.present) {
+      map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('LocalAssetEntityCompanion(')
+          ..write('name: $name, ')
+          ..write('type: $type, ')
+          ..write('createdAt: $createdAt, ')
+          ..write('updatedAt: $updatedAt, ')
+          ..write('durationInSeconds: $durationInSeconds, ')
+          ..write('id: $id, ')
+          ..write('checksum: $checksum, ')
+          ..write('isFavorite: $isFavorite')
+          ..write(')'))
+        .toString();
+  }
+}
diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart
index 997714e1b6..17fcad76bf 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.dart
@@ -3,6 +3,9 @@ import 'dart:async';
 import 'package:drift/drift.dart';
 import 'package:drift_flutter/drift_flutter.dart';
 import 'package:immich_mobile/domain/interfaces/db.interface.dart';
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
+import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
 import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
 import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
 import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
@@ -25,7 +28,16 @@ class IsarDatabaseRepository implements IDatabaseRepository {
       Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback();
 }
 
-@DriftDatabase(tables: [UserEntity, UserMetadataEntity, PartnerEntity])
+@DriftDatabase(
+  tables: [
+    UserEntity,
+    UserMetadataEntity,
+    PartnerEntity,
+    LocalAlbumEntity,
+    LocalAssetEntity,
+    LocalAlbumAssetEntity,
+  ],
+)
 class Drift extends $Drift implements IDatabaseRepository {
   Drift([QueryExecutor? executor])
       : super(
@@ -42,8 +54,9 @@ class Drift extends $Drift implements IDatabaseRepository {
   @override
   MigrationStrategy get migration => MigrationStrategy(
         beforeOpen: (details) async {
-          await customStatement('PRAGMA journal_mode = WAL');
           await customStatement('PRAGMA foreign_keys = ON');
+          await customStatement('PRAGMA synchronous = NORMAL');
+          await customStatement('PRAGMA journal_mode = WAL');
         },
       );
 }
diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart
index a4c2b31dcd..6611eb5c92 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart
@@ -7,6 +7,12 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
     as i2;
 import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
     as i3;
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
+    as i4;
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
+    as i5;
+import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
+    as i6;
 
 abstract class $Drift extends i0.GeneratedDatabase {
   $Drift(i0.QueryExecutor e) : super(e);
@@ -16,12 +22,25 @@ abstract class $Drift extends i0.GeneratedDatabase {
       i2.$UserMetadataEntityTable(this);
   late final i3.$PartnerEntityTable partnerEntity =
       i3.$PartnerEntityTable(this);
+  late final i4.$LocalAlbumEntityTable localAlbumEntity =
+      i4.$LocalAlbumEntityTable(this);
+  late final i5.$LocalAssetEntityTable localAssetEntity =
+      i5.$LocalAssetEntityTable(this);
+  late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
+      i6.$LocalAlbumAssetEntityTable(this);
   @override
   Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
       allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
   @override
-  List<i0.DatabaseSchemaEntity> get allSchemaEntities =>
-      [userEntity, userMetadataEntity, partnerEntity];
+  List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
+        userEntity,
+        userMetadataEntity,
+        partnerEntity,
+        localAlbumEntity,
+        localAssetEntity,
+        localAlbumAssetEntity,
+        i5.localAssetChecksum
+      ];
   @override
   i0.StreamQueryUpdateRules get streamUpdateRules =>
       const i0.StreamQueryUpdateRules(
@@ -48,6 +67,22 @@ abstract class $Drift extends i0.GeneratedDatabase {
               i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete),
             ],
           ),
+          i0.WritePropagation(
+            on: i0.TableUpdateQuery.onTableName('local_asset_entity',
+                limitUpdateKind: i0.UpdateKind.delete),
+            result: [
+              i0.TableUpdate('local_album_asset_entity',
+                  kind: i0.UpdateKind.delete),
+            ],
+          ),
+          i0.WritePropagation(
+            on: i0.TableUpdateQuery.onTableName('local_album_entity',
+                limitUpdateKind: i0.UpdateKind.delete),
+            result: [
+              i0.TableUpdate('local_album_asset_entity',
+                  kind: i0.UpdateKind.delete),
+            ],
+          ),
         ],
       );
   @override
@@ -64,4 +99,10 @@ class $DriftManager {
       i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
   i3.$$PartnerEntityTableTableManager get partnerEntity =>
       i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
+  i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
+      i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
+  i5.$$LocalAssetEntityTableTableManager get localAssetEntity =>
+      i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
+  i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
+      .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
 }
diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart
new file mode 100644
index 0000000000..650b7a1aab
--- /dev/null
+++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart
@@ -0,0 +1,366 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/local_album.model.dart';
+import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
+import 'package:platform/platform.dart';
+
+class DriftLocalAlbumRepository extends DriftDatabaseRepository
+    implements ILocalAlbumRepository {
+  final Drift _db;
+  final Platform _platform;
+  const DriftLocalAlbumRepository(this._db, {Platform? platform})
+      : _platform = platform ?? const LocalPlatform(),
+        super(_db);
+
+  @override
+  Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
+    final assetCount = _db.localAlbumAssetEntity.assetId.count();
+
+    final query = _db.localAlbumEntity.select().join([
+      leftOuterJoin(
+        _db.localAlbumAssetEntity,
+        _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
+        useColumns: false,
+      ),
+    ]);
+    query
+      ..addColumns([assetCount])
+      ..groupBy([_db.localAlbumEntity.id]);
+    if (sortBy == SortLocalAlbumsBy.id) {
+      query.orderBy([OrderingTerm.asc(_db.localAlbumEntity.id)]);
+    }
+    return query
+        .map(
+          (row) => row
+              .readTable(_db.localAlbumEntity)
+              .toDto(assetCount: row.read(assetCount) ?? 0),
+        )
+        .get();
+  }
+
+  @override
+  Future<void> delete(String albumId) => transaction(() async {
+        // Remove all assets that are only in this particular album
+        // We cannot remove all assets in the album because they might be in other albums in iOS
+        // That is not the case on Android since asset <-> album has one:one mapping
+        final assetsToDelete = _platform.isIOS
+            ? await _getUniqueAssetsInAlbum(albumId)
+            : await getAssetIdsForAlbum(albumId);
+        await _deleteAssets(assetsToDelete);
+
+        // All the other assets that are still associated will be unlinked automatically on-cascade
+        await _db.managers.localAlbumEntity
+            .filter((a) => a.id.equals(albumId))
+            .delete();
+      });
+
+  @override
+  Future<void> syncAlbumDeletes(
+    String albumId,
+    Iterable<String> assetIdsToKeep,
+  ) async {
+    if (assetIdsToKeep.isEmpty) {
+      return Future.value();
+    }
+
+    final deleteSmt = _db.localAssetEntity.delete();
+    deleteSmt.where((localAsset) {
+      final subQuery = _db.localAlbumAssetEntity.selectOnly()
+        ..addColumns([_db.localAlbumAssetEntity.assetId])
+        ..join([
+          innerJoin(
+            _db.localAlbumEntity,
+            _db.localAlbumAssetEntity.albumId
+                .equalsExp(_db.localAlbumEntity.id),
+          ),
+        ]);
+      subQuery.where(
+        _db.localAlbumEntity.id.equals(albumId) &
+            _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
+      );
+      return localAsset.id.isInQuery(subQuery);
+    });
+    await deleteSmt.go();
+  }
+
+  @override
+  Future<void> upsert(
+    LocalAlbum localAlbum, {
+    Iterable<LocalAsset> toUpsert = const [],
+    Iterable<String> toDelete = const [],
+  }) {
+    final companion = LocalAlbumEntityCompanion.insert(
+      id: localAlbum.id,
+      name: localAlbum.name,
+      updatedAt: Value(localAlbum.updatedAt),
+      backupSelection: localAlbum.backupSelection,
+    );
+
+    return _db.transaction(() async {
+      await _db.localAlbumEntity
+          .insertOne(companion, onConflict: DoUpdate((_) => companion));
+      await _addAssets(localAlbum.id, toUpsert);
+      await _removeAssets(localAlbum.id, toDelete);
+    });
+  }
+
+  @override
+  Future<void> updateAll(Iterable<LocalAlbum> albums) {
+    return _db.transaction(() async {
+      await _db.localAlbumEntity
+          .update()
+          .write(const LocalAlbumEntityCompanion(marker_: Value(true)));
+
+      await _db.batch((batch) {
+        for (final album in albums) {
+          final companion = LocalAlbumEntityCompanion.insert(
+            id: album.id,
+            name: album.name,
+            updatedAt: Value(album.updatedAt),
+            backupSelection: album.backupSelection,
+            marker_: const Value(null),
+          );
+
+          batch.insert(
+            _db.localAlbumEntity,
+            companion,
+            onConflict: DoUpdate((_) => companion),
+          );
+        }
+      });
+
+      if (_platform.isAndroid) {
+        // On Android, an asset can only be in one album
+        // So, get the albums that are marked for deletion
+        // and delete all the assets that are in those albums
+        final deleteSmt = _db.localAssetEntity.delete();
+        deleteSmt.where((localAsset) {
+          final subQuery = _db.localAlbumAssetEntity.selectOnly()
+            ..addColumns([_db.localAlbumAssetEntity.assetId])
+            ..join([
+              innerJoin(
+                _db.localAlbumEntity,
+                _db.localAlbumAssetEntity.albumId
+                    .equalsExp(_db.localAlbumEntity.id),
+              ),
+            ]);
+          subQuery.where(_db.localAlbumEntity.marker_.isNotNull());
+          return localAsset.id.isInQuery(subQuery);
+        });
+        await deleteSmt.go();
+      }
+
+      await _db.localAlbumEntity.deleteWhere((f) => f.marker_.isNotNull());
+    });
+  }
+
+  @override
+  Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
+    final query = _db.localAlbumAssetEntity.select().join(
+      [
+        innerJoin(
+          _db.localAssetEntity,
+          _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
+        ),
+      ],
+    )
+      ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
+      ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
+    return query
+        .map((row) => row.readTable(_db.localAssetEntity).toDto())
+        .get();
+  }
+
+  @override
+  Future<List<String>> getAssetIdsForAlbum(String albumId) {
+    final query = _db.localAlbumAssetEntity.selectOnly()
+      ..addColumns([_db.localAlbumAssetEntity.assetId])
+      ..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
+    return query
+        .map((row) => row.read(_db.localAlbumAssetEntity.assetId)!)
+        .get();
+  }
+
+  @override
+  Future<void> processDelta({
+    required List<LocalAsset> updates,
+    required List<String> deletes,
+    required Map<String, List<String>> assetAlbums,
+  }) {
+    return _db.transaction(() async {
+      await _deleteAssets(deletes);
+
+      await _upsertAssets(updates);
+      // The ugly casting below is required for now because the generated code
+      // casts the returned values from the platform during decoding them
+      // and iterating over them causes the type to be List<Object?> instead of
+      // List<String>
+      await _db.batch((batch) async {
+        assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
+          batch.deleteWhere(
+            _db.localAlbumAssetEntity,
+            (f) =>
+                f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) &
+                f.assetId.equals(assetId),
+          );
+        });
+      });
+      await _db.batch((batch) async {
+        assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
+          batch.insertAll(
+            _db.localAlbumAssetEntity,
+            albumIds.cast<String?>().nonNulls.map(
+                  (albumId) => LocalAlbumAssetEntityCompanion.insert(
+                    assetId: assetId,
+                    albumId: albumId,
+                  ),
+                ),
+            onConflict: DoNothing(),
+          );
+        });
+      });
+    });
+  }
+
+  Future<void> _addAssets(String albumId, Iterable<LocalAsset> assets) {
+    if (assets.isEmpty) {
+      return Future.value();
+    }
+    return transaction(() async {
+      await _upsertAssets(assets);
+      await _db.localAlbumAssetEntity.insertAll(
+        assets.map(
+          (a) => LocalAlbumAssetEntityCompanion.insert(
+            assetId: a.id,
+            albumId: albumId,
+          ),
+        ),
+        mode: InsertMode.insertOrIgnore,
+      );
+    });
+  }
+
+  Future<void> _removeAssets(String albumId, Iterable<String> assetIds) async {
+    if (assetIds.isEmpty) {
+      return Future.value();
+    }
+
+    if (_platform.isAndroid) {
+      return _deleteAssets(assetIds);
+    }
+
+    List<String> assetsToDelete = [];
+    List<String> assetsToUnLink = [];
+
+    final uniqueAssets = await _getUniqueAssetsInAlbum(albumId);
+    if (uniqueAssets.isEmpty) {
+      assetsToUnLink = assetIds.toList();
+    } else {
+      // Delete unique assets and unlink others
+      final uniqueSet = uniqueAssets.toSet();
+
+      for (final assetId in assetIds) {
+        if (uniqueSet.contains(assetId)) {
+          assetsToDelete.add(assetId);
+        } else {
+          assetsToUnLink.add(assetId);
+        }
+      }
+    }
+
+    return transaction(() async {
+      if (assetsToUnLink.isNotEmpty) {
+        await _db.batch(
+          (batch) => batch.deleteWhere(
+            _db.localAlbumAssetEntity,
+            (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
+          ),
+        );
+      }
+
+      await _deleteAssets(assetsToDelete);
+    });
+  }
+
+  /// Get all asset ids that are only in this album and not in other albums.
+  /// This is useful in cases where the album is a smart album or a user-created album, especially on iOS
+  Future<List<String>> _getUniqueAssetsInAlbum(String albumId) {
+    final assetId = _db.localAlbumAssetEntity.assetId;
+    final query = _db.localAlbumAssetEntity.selectOnly()
+      ..addColumns([assetId])
+      ..groupBy(
+        [assetId],
+        having: _db.localAlbumAssetEntity.albumId.count().equals(1) &
+            _db.localAlbumAssetEntity.albumId.equals(albumId),
+      );
+
+    return query.map((row) => row.read(assetId)!).get();
+  }
+
+  Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
+    if (localAssets.isEmpty) {
+      return Future.value();
+    }
+
+    return _db.batch((batch) async {
+      batch.insertAllOnConflictUpdate(
+        _db.localAssetEntity,
+        localAssets.map(
+          (a) => LocalAssetEntityCompanion.insert(
+            name: a.name,
+            type: a.type,
+            createdAt: Value(a.createdAt),
+            updatedAt: Value(a.updatedAt),
+            durationInSeconds: Value.absentIfNull(a.durationInSeconds),
+            id: a.id,
+            checksum: Value.absentIfNull(a.checksum),
+          ),
+        ),
+      );
+    });
+  }
+
+  Future<void> _deleteAssets(Iterable<String> ids) {
+    if (ids.isEmpty) {
+      return Future.value();
+    }
+
+    return _db.batch(
+      (batch) => batch.deleteWhere(
+        _db.localAssetEntity,
+        (f) => f.id.isIn(ids),
+      ),
+    );
+  }
+}
+
+extension on LocalAlbumEntityData {
+  LocalAlbum toDto({int assetCount = 0}) {
+    return LocalAlbum(
+      id: id,
+      name: name,
+      updatedAt: updatedAt,
+      assetCount: assetCount,
+      backupSelection: backupSelection,
+    );
+  }
+}
+
+extension on LocalAssetEntityData {
+  LocalAsset toDto() {
+    return LocalAsset(
+      id: id,
+      name: name,
+      checksum: checksum,
+      type: type,
+      createdAt: createdAt,
+      updatedAt: updatedAt,
+      durationInSeconds: durationInSeconds,
+      isFavorite: isFavorite,
+    );
+  }
+}
diff --git a/mobile/lib/infrastructure/utils/asset.mixin.dart b/mobile/lib/infrastructure/utils/asset.mixin.dart
new file mode 100644
index 0000000000..8649550826
--- /dev/null
+++ b/mobile/lib/infrastructure/utils/asset.mixin.dart
@@ -0,0 +1,10 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+
+mixin AssetEntityMixin on Table {
+  TextColumn get name => text()();
+  IntColumn get type => intEnum<AssetType>()();
+  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
+  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
+  IntColumn get durationInSeconds => integer().nullable()();
+}
diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart
new file mode 100644
index 0000000000..c4e4c467d4
--- /dev/null
+++ b/mobile/lib/platform/native_sync_api.g.dart
@@ -0,0 +1,501 @@
+// Autogenerated from Pigeon (v25.3.2), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+PlatformException _createConnectionError(String channelName) {
+  return PlatformException(
+    code: 'channel-error',
+    message: 'Unable to establish connection on channel: "$channelName".',
+  );
+}
+
+bool _deepEquals(Object? a, Object? b) {
+  if (a is List && b is List) {
+    return a.length == b.length &&
+        a.indexed
+            .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
+  }
+  if (a is Map && b is Map) {
+    return a.length == b.length &&
+        a.entries.every((MapEntry<Object?, Object?> entry) =>
+            (b as Map<Object?, Object?>).containsKey(entry.key) &&
+            _deepEquals(entry.value, b[entry.key]));
+  }
+  return a == b;
+}
+
+class PlatformAsset {
+  PlatformAsset({
+    required this.id,
+    required this.name,
+    required this.type,
+    this.createdAt,
+    this.updatedAt,
+    required this.durationInSeconds,
+  });
+
+  String id;
+
+  String name;
+
+  int type;
+
+  int? createdAt;
+
+  int? updatedAt;
+
+  int durationInSeconds;
+
+  List<Object?> _toList() {
+    return <Object?>[
+      id,
+      name,
+      type,
+      createdAt,
+      updatedAt,
+      durationInSeconds,
+    ];
+  }
+
+  Object encode() {
+    return _toList();
+  }
+
+  static PlatformAsset decode(Object result) {
+    result as List<Object?>;
+    return PlatformAsset(
+      id: result[0]! as String,
+      name: result[1]! as String,
+      type: result[2]! as int,
+      createdAt: result[3] as int?,
+      updatedAt: result[4] as int?,
+      durationInSeconds: result[5]! as int,
+    );
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  bool operator ==(Object other) {
+    if (other is! PlatformAsset || 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 PlatformAlbum {
+  PlatformAlbum({
+    required this.id,
+    required this.name,
+    this.updatedAt,
+    required this.isCloud,
+    required this.assetCount,
+  });
+
+  String id;
+
+  String name;
+
+  int? updatedAt;
+
+  bool isCloud;
+
+  int assetCount;
+
+  List<Object?> _toList() {
+    return <Object?>[
+      id,
+      name,
+      updatedAt,
+      isCloud,
+      assetCount,
+    ];
+  }
+
+  Object encode() {
+    return _toList();
+  }
+
+  static PlatformAlbum decode(Object result) {
+    result as List<Object?>;
+    return PlatformAlbum(
+      id: result[0]! as String,
+      name: result[1]! as String,
+      updatedAt: result[2] as int?,
+      isCloud: result[3]! as bool,
+      assetCount: result[4]! as int,
+    );
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  bool operator ==(Object other) {
+    if (other is! PlatformAlbum || 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 SyncDelta {
+  SyncDelta({
+    required this.hasChanges,
+    required this.updates,
+    required this.deletes,
+    required this.assetAlbums,
+  });
+
+  bool hasChanges;
+
+  List<PlatformAsset> updates;
+
+  List<String> deletes;
+
+  Map<String, List<String>> assetAlbums;
+
+  List<Object?> _toList() {
+    return <Object?>[
+      hasChanges,
+      updates,
+      deletes,
+      assetAlbums,
+    ];
+  }
+
+  Object encode() {
+    return _toList();
+  }
+
+  static SyncDelta decode(Object result) {
+    result as List<Object?>;
+    return SyncDelta(
+      hasChanges: result[0]! as bool,
+      updates: (result[1] as List<Object?>?)!.cast<PlatformAsset>(),
+      deletes: (result[2] as List<Object?>?)!.cast<String>(),
+      assetAlbums:
+          (result[3] as Map<Object?, Object?>?)!.cast<String, List<String>>(),
+    );
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  bool operator ==(Object other) {
+    if (other is! SyncDelta || 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 {
+  const _PigeonCodec();
+  @override
+  void writeValue(WriteBuffer buffer, Object? value) {
+    if (value is int) {
+      buffer.putUint8(4);
+      buffer.putInt64(value);
+    } else if (value is PlatformAsset) {
+      buffer.putUint8(129);
+      writeValue(buffer, value.encode());
+    } else if (value is PlatformAlbum) {
+      buffer.putUint8(130);
+      writeValue(buffer, value.encode());
+    } else if (value is SyncDelta) {
+      buffer.putUint8(131);
+      writeValue(buffer, value.encode());
+    } else {
+      super.writeValue(buffer, value);
+    }
+  }
+
+  @override
+  Object? readValueOfType(int type, ReadBuffer buffer) {
+    switch (type) {
+      case 129:
+        return PlatformAsset.decode(readValue(buffer)!);
+      case 130:
+        return PlatformAlbum.decode(readValue(buffer)!);
+      case 131:
+        return SyncDelta.decode(readValue(buffer)!);
+      default:
+        return super.readValueOfType(type, buffer);
+    }
+  }
+}
+
+class NativeSyncApi {
+  /// Constructor for [NativeSyncApi].  The [binaryMessenger] named argument is
+  /// available for dependency injection.  If it is left null, the default
+  /// BinaryMessenger will be used which routes to the host platform.
+  NativeSyncApi(
+      {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
+      : pigeonVar_binaryMessenger = binaryMessenger,
+        pigeonVar_messageChannelSuffix =
+            messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
+  final BinaryMessenger? pigeonVar_binaryMessenger;
+
+  static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
+
+  final String pigeonVar_messageChannelSuffix;
+
+  Future<bool> shouldFullSync() async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as bool?)!;
+    }
+  }
+
+  Future<SyncDelta> getMediaChanges() async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as SyncDelta?)!;
+    }
+  }
+
+  Future<void> checkpointSync() async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$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;
+    }
+  }
+
+  Future<void> clearSyncCheckpoint() async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$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;
+    }
+  }
+
+  Future<List<String>> getAssetIdsForAlbum(String albumId) async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
+    final BasicMessageChannel<Object?> pigeonVar_channel =
+        BasicMessageChannel<Object?>(
+      pigeonVar_channelName,
+      pigeonChannelCodec,
+      binaryMessenger: pigeonVar_binaryMessenger,
+    );
+    final Future<Object?> pigeonVar_sendFuture =
+        pigeonVar_channel.send(<Object?>[albumId]);
+    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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as List<Object?>?)!.cast<String>();
+    }
+  }
+
+  Future<List<PlatformAlbum>> getAlbums() async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAlbum>();
+    }
+  }
+
+  Future<int> getAssetsCountSince(String albumId, int timestamp) async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
+    final BasicMessageChannel<Object?> pigeonVar_channel =
+        BasicMessageChannel<Object?>(
+      pigeonVar_channelName,
+      pigeonChannelCodec,
+      binaryMessenger: pigeonVar_binaryMessenger,
+    );
+    final Future<Object?> pigeonVar_sendFuture =
+        pigeonVar_channel.send(<Object?>[albumId, timestamp]);
+    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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as int?)!;
+    }
+  }
+
+  Future<List<PlatformAsset>> getAssetsForAlbum(String albumId,
+      {int? updatedTimeCond}) async {
+    final String pigeonVar_channelName =
+        'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
+    final BasicMessageChannel<Object?> pigeonVar_channel =
+        BasicMessageChannel<Object?>(
+      pigeonVar_channelName,
+      pigeonChannelCodec,
+      binaryMessenger: pigeonVar_binaryMessenger,
+    );
+    final Future<Object?> pigeonVar_sendFuture =
+        pigeonVar_channel.send(<Object?>[albumId, updatedTimeCond]);
+    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 if (pigeonVar_replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
+    }
+  }
+}
diff --git a/mobile/lib/presentation/pages/dev/dev_logger.dart b/mobile/lib/presentation/pages/dev/dev_logger.dart
new file mode 100644
index 0000000000..6d179241a4
--- /dev/null
+++ b/mobile/lib/presentation/pages/dev/dev_logger.dart
@@ -0,0 +1,68 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:immich_mobile/domain/models/log.model.dart';
+import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
+import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
+// ignore: import_rule_isar
+import 'package:isar/isar.dart';
+
+const kDevLoggerTag = 'DEV';
+
+abstract final class DLog {
+  const DLog();
+
+  static Stream<List<LogMessage>> watchLog() {
+    final db = Isar.getInstance();
+    if (db == null) {
+      debugPrint('Isar is not initialized');
+      return const Stream.empty();
+    }
+
+    return db.loggerMessages
+        .filter()
+        .context1EqualTo(kDevLoggerTag)
+        .sortByCreatedAtDesc()
+        .watch(fireImmediately: true)
+        .map((logs) => logs.map((log) => log.toDto()).toList());
+  }
+
+  static void clearLog() {
+    final db = Isar.getInstance();
+    if (db == null) {
+      debugPrint('Isar is not initialized');
+      return;
+    }
+
+    db.writeTxnSync(() {
+      db.loggerMessages.filter().context1EqualTo(kDevLoggerTag).deleteAllSync();
+    });
+  }
+
+  static void log(String message, [Object? error, StackTrace? stackTrace]) {
+    debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message');
+    if (error != null) {
+      debugPrint('Error: $error');
+    }
+    if (stackTrace != null) {
+      debugPrint('StackTrace: $stackTrace');
+    }
+
+    final isar = Isar.getInstance();
+    if (isar == null) {
+      debugPrint('Isar is not initialized');
+      return;
+    }
+
+    final record = LogMessage(
+      message: message,
+      level: LogLevel.info,
+      createdAt: DateTime.now(),
+      logger: kDevLoggerTag,
+      error: error?.toString(),
+      stack: stackTrace?.toString(),
+    );
+
+    unawaited(IsarLogRepository(isar).insert(record));
+  }
+}
diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart
new file mode 100644
index 0000000000..da0bea157f
--- /dev/null
+++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart
@@ -0,0 +1,174 @@
+// ignore_for_file: avoid-local-functions
+
+import 'dart:async';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:drift/drift.dart' hide Column;
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/theme_extensions.dart';
+import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
+import 'package:immich_mobile/providers/background_sync.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+
+final _features = [
+  _Feature(
+    name: 'Sync Local',
+    icon: Icons.photo_album_rounded,
+    onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
+  ),
+  _Feature(
+    name: 'Sync Local Full',
+    icon: Icons.photo_library_rounded,
+    onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
+  ),
+  _Feature(
+    name: 'Sync Remote',
+    icon: Icons.refresh_rounded,
+    onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
+  ),
+  _Feature(
+    name: 'WAL Checkpoint',
+    icon: Icons.save_rounded,
+    onTap: (_, ref) => ref
+        .read(driftProvider)
+        .customStatement("pragma wal_checkpoint(truncate)"),
+  ),
+  _Feature(
+    name: 'Clear Delta Checkpoint',
+    icon: Icons.delete_rounded,
+    onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
+  ),
+  _Feature(
+    name: 'Clear Local Data',
+    icon: Icons.delete_forever_rounded,
+    onTap: (_, ref) async {
+      final db = ref.read(driftProvider);
+      await db.localAssetEntity.deleteAll();
+      await db.localAlbumEntity.deleteAll();
+      await db.localAlbumAssetEntity.deleteAll();
+    },
+  ),
+  _Feature(
+    name: 'Local Media Summary',
+    icon: Icons.table_chart_rounded,
+    onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
+  ),
+];
+
+@RoutePage()
+class FeatInDevPage extends StatelessWidget {
+  const FeatInDevPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('Features in Development'),
+        centerTitle: true,
+      ),
+      body: Column(
+        children: [
+          Flexible(
+            flex: 1,
+            child: ListView.builder(
+              itemBuilder: (_, index) {
+                final feat = _features[index];
+                return Consumer(
+                  builder: (ctx, ref, _) => ListTile(
+                    title: Text(feat.name),
+                    trailing: Icon(feat.icon),
+                    visualDensity: VisualDensity.compact,
+                    onTap: () => unawaited(feat.onTap(ctx, ref)),
+                  ),
+                );
+              },
+              itemCount: _features.length,
+            ),
+          ),
+          const Divider(height: 0),
+          const Flexible(child: _DevLogs()),
+        ],
+      ),
+    );
+  }
+}
+
+class _Feature {
+  const _Feature({
+    required this.name,
+    required this.icon,
+    required this.onTap,
+  });
+
+  final String name;
+  final IconData icon;
+  final Future<void> Function(BuildContext, WidgetRef _) onTap;
+}
+
+// ignore: prefer-single-widget-per-file
+class _DevLogs extends StatelessWidget {
+  const _DevLogs();
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        automaticallyImplyLeading: false,
+        actions: [
+          IconButton(
+            onPressed: DLog.clearLog,
+            icon: Icon(
+              Icons.delete_outline_rounded,
+              size: 20.0,
+              color: context.primaryColor,
+              semanticLabel: "Clear logs",
+            ),
+          ),
+        ],
+        centerTitle: true,
+      ),
+      body: StreamBuilder(
+        initialData: [],
+        stream: DLog.watchLog(),
+        builder: (_, logMessages) {
+          return ListView.separated(
+            itemBuilder: (ctx, index) {
+              // ignore: avoid-unsafe-collection-methods
+              final logMessage = logMessages.data![index];
+              return ListTile(
+                title: Text(
+                  logMessage.message,
+                  style: TextStyle(
+                    color: ctx.colorScheme.onSurface,
+                    fontSize: 14.0,
+                    overflow: TextOverflow.ellipsis,
+                  ),
+                ),
+                subtitle: Text(
+                  "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+                  style: TextStyle(
+                    color: ctx.colorScheme.onSurfaceSecondary,
+                    fontSize: 12.0,
+                  ),
+                ),
+                dense: true,
+                visualDensity: VisualDensity.compact,
+                tileColor: Colors.transparent,
+                minLeadingWidth: 10,
+              );
+            },
+            separatorBuilder: (_, index) {
+              return const Divider(height: 0);
+            },
+            itemCount: logMessages.data?.length ?? 0,
+          );
+        },
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/presentation/pages/dev/local_media_stat.page.dart b/mobile/lib/presentation/pages/dev/local_media_stat.page.dart
new file mode 100644
index 0000000000..b42cae84fe
--- /dev/null
+++ b/mobile/lib/presentation/pages/dev/local_media_stat.page.dart
@@ -0,0 +1,125 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/local_album.model.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
+import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
+
+final _stats = [
+  _Stat(
+    name: 'Local Assets',
+    load: (db) => db.managers.localAssetEntity.count(),
+  ),
+  _Stat(
+    name: 'Local Albums',
+    load: (db) => db.managers.localAlbumEntity.count(),
+  ),
+];
+
+@RoutePage()
+class LocalMediaSummaryPage extends StatelessWidget {
+  const LocalMediaSummaryPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Local Media Summary')),
+      body: Consumer(
+        builder: (ctx, ref, __) {
+          final db = ref.watch(driftProvider);
+          final albumsFuture = ref.watch(localAlbumRepository).getAll();
+
+          return CustomScrollView(
+            slivers: [
+              SliverList.builder(
+                itemBuilder: (_, index) {
+                  final stat = _stats[index];
+                  final countFuture = stat.load(db);
+                  return _Summary(name: stat.name, countFuture: countFuture);
+                },
+                itemCount: _stats.length,
+              ),
+              SliverToBoxAdapter(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  children: [
+                    const Divider(),
+                    Padding(
+                      padding: const EdgeInsets.only(left: 15),
+                      child: Text(
+                        "Album summary",
+                        style: ctx.textTheme.titleMedium,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+              FutureBuilder(
+                future: albumsFuture,
+                initialData: <LocalAlbum>[],
+                builder: (_, snap) {
+                  final albums = snap.data!;
+                  if (albums.isEmpty) {
+                    return const SliverToBoxAdapter(child: SizedBox.shrink());
+                  }
+
+                  albums.sortBy((a) => a.name);
+                  return SliverList.builder(
+                    itemBuilder: (_, index) {
+                      final album = albums[index];
+                      final countFuture = db.managers.localAlbumAssetEntity
+                          .filter((f) => f.albumId.id.equals(album.id))
+                          .count();
+                      return _Summary(
+                        name: album.name,
+                        countFuture: countFuture,
+                      );
+                    },
+                    itemCount: albums.length,
+                  );
+                },
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}
+
+// ignore: prefer-single-widget-per-file
+class _Summary extends StatelessWidget {
+  final String name;
+  final Future<int> countFuture;
+
+  const _Summary({required this.name, required this.countFuture});
+
+  @override
+  Widget build(BuildContext context) {
+    return FutureBuilder<int>(
+      future: countFuture,
+      builder: (ctx, snapshot) {
+        final Widget subtitle;
+
+        if (snapshot.connectionState == ConnectionState.waiting) {
+          subtitle = const CircularProgressIndicator();
+        } else if (snapshot.hasError) {
+          subtitle = const Icon(Icons.error_rounded);
+        } else {
+          subtitle = Text('${snapshot.data ?? 0}');
+        }
+        return ListTile(title: Text(name), trailing: subtitle);
+      },
+    );
+  }
+}
+
+class _Stat {
+  const _Stat({required this.name, required this.load});
+
+  final String name;
+  final Future<int> Function(Drift _) load;
+}
diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart
new file mode 100644
index 0000000000..cb4aadb8a7
--- /dev/null
+++ b/mobile/lib/providers/infrastructure/album.provider.dart
@@ -0,0 +1,8 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
+import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
+import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
+
+final localAlbumRepository = Provider<ILocalAlbumRepository>(
+  (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
+);
diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart
new file mode 100644
index 0000000000..477046d0bf
--- /dev/null
+++ b/mobile/lib/providers/infrastructure/platform.provider.dart
@@ -0,0 +1,4 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/platform/native_sync_api.g.dart';
+
+final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
diff --git a/mobile/lib/providers/infrastructure/sync_stream.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart
similarity index 64%
rename from mobile/lib/providers/infrastructure/sync_stream.provider.dart
rename to mobile/lib/providers/infrastructure/sync.provider.dart
index e313982a30..96e470eba2 100644
--- a/mobile/lib/providers/infrastructure/sync_stream.provider.dart
+++ b/mobile/lib/providers/infrastructure/sync.provider.dart
@@ -1,10 +1,14 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/services/local_sync.service.dart';
 import 'package:immich_mobile/domain/services/sync_stream.service.dart';
 import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
 import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
 import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/album.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/platform.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
 
 final syncStreamServiceProvider = Provider(
   (ref) => SyncStreamService(
@@ -21,3 +25,11 @@ final syncApiRepositoryProvider = Provider(
 final syncStreamRepositoryProvider = Provider(
   (ref) => DriftSyncStreamRepository(ref.watch(driftProvider)),
 );
+
+final localSyncServiceProvider = Provider(
+  (ref) => LocalSyncService(
+    localAlbumRepository: ref.watch(localAlbumRepository),
+    nativeSyncApi: ref.watch(nativeSyncApiProvider),
+    storeService: ref.watch(storeServiceProvider),
+  ),
+);
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index 317ce7cc54..a6e1d89ff3 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -63,6 +63,8 @@ import 'package:immich_mobile/pages/search/person_result.page.dart';
 import 'package:immich_mobile/pages/search/recently_taken.page.dart';
 import 'package:immich_mobile/pages/search/search.page.dart';
 import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
+import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
+import 'package:immich_mobile/presentation/pages/dev/local_media_stat.page.dart';
 import 'package:immich_mobile/providers/api.provider.dart';
 import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
@@ -316,5 +318,13 @@ class AppRouter extends RootStackRouter {
       page: PinAuthRoute.page,
       guards: [_authGuard, _duplicateGuard],
     ),
+    AutoRoute(
+      page: FeatInDevRoute.page,
+      guards: [_authGuard, _duplicateGuard],
+    ),
+    AutoRoute(
+      page: LocalMediaSummaryRoute.page,
+      guards: [_authGuard, _duplicateGuard],
+    ),
   ];
 }
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index da488779e6..57fb8cef80 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -1,3 +1,4 @@
+// dart format width=80
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 // **************************************************************************
@@ -13,10 +14,7 @@ part of 'router.dart';
 /// [ActivitiesPage]
 class ActivitiesRoute extends PageRouteInfo<void> {
   const ActivitiesRoute({List<PageRouteInfo>? children})
-      : super(
-          ActivitiesRoute.name,
-          initialChildren: children,
-        );
+      : super(ActivitiesRoute.name, initialChildren: children);
 
   static const String name = 'ActivitiesRoute';
 
@@ -132,10 +130,7 @@ class AlbumAssetSelectionRouteArgs {
 /// [AlbumOptionsPage]
 class AlbumOptionsRoute extends PageRouteInfo<void> {
   const AlbumOptionsRoute({List<PageRouteInfo>? children})
-      : super(
-          AlbumOptionsRoute.name,
-          initialChildren: children,
-        );
+      : super(AlbumOptionsRoute.name, initialChildren: children);
 
   static const String name = 'AlbumOptionsRoute';
 
@@ -156,10 +151,7 @@ class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           AlbumPreviewRoute.name,
-          args: AlbumPreviewRouteArgs(
-            key: key,
-            album: album,
-          ),
+          args: AlbumPreviewRouteArgs(key: key, album: album),
           initialChildren: children,
         );
 
@@ -169,19 +161,13 @@ class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<AlbumPreviewRouteArgs>();
-      return AlbumPreviewPage(
-        key: args.key,
-        album: args.album,
-      );
+      return AlbumPreviewPage(key: args.key, album: args.album);
     },
   );
 }
 
 class AlbumPreviewRouteArgs {
-  const AlbumPreviewRouteArgs({
-    this.key,
-    required this.album,
-  });
+  const AlbumPreviewRouteArgs({this.key, required this.album});
 
   final Key? key;
 
@@ -203,10 +189,7 @@ class AlbumSharedUserSelectionRoute
     List<PageRouteInfo>? children,
   }) : super(
           AlbumSharedUserSelectionRoute.name,
-          args: AlbumSharedUserSelectionRouteArgs(
-            key: key,
-            assets: assets,
-          ),
+          args: AlbumSharedUserSelectionRouteArgs(key: key, assets: assets),
           initialChildren: children,
         );
 
@@ -216,19 +199,13 @@ class AlbumSharedUserSelectionRoute
     name,
     builder: (data) {
       final args = data.argsAs<AlbumSharedUserSelectionRouteArgs>();
-      return AlbumSharedUserSelectionPage(
-        key: args.key,
-        assets: args.assets,
-      );
+      return AlbumSharedUserSelectionPage(key: args.key, assets: args.assets);
     },
   );
 }
 
 class AlbumSharedUserSelectionRouteArgs {
-  const AlbumSharedUserSelectionRouteArgs({
-    this.key,
-    required this.assets,
-  });
+  const AlbumSharedUserSelectionRouteArgs({this.key, required this.assets});
 
   final Key? key;
 
@@ -249,10 +226,7 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           AlbumViewerRoute.name,
-          args: AlbumViewerRouteArgs(
-            key: key,
-            albumId: albumId,
-          ),
+          args: AlbumViewerRouteArgs(key: key, albumId: albumId),
           initialChildren: children,
         );
 
@@ -262,19 +236,13 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<AlbumViewerRouteArgs>();
-      return AlbumViewerPage(
-        key: args.key,
-        albumId: args.albumId,
-      );
+      return AlbumViewerPage(key: args.key, albumId: args.albumId);
     },
   );
 }
 
 class AlbumViewerRouteArgs {
-  const AlbumViewerRouteArgs({
-    this.key,
-    required this.albumId,
-  });
+  const AlbumViewerRouteArgs({this.key, required this.albumId});
 
   final Key? key;
 
@@ -290,10 +258,7 @@ class AlbumViewerRouteArgs {
 /// [AlbumsPage]
 class AlbumsRoute extends PageRouteInfo<void> {
   const AlbumsRoute({List<PageRouteInfo>? children})
-      : super(
-          AlbumsRoute.name,
-          initialChildren: children,
-        );
+      : super(AlbumsRoute.name, initialChildren: children);
 
   static const String name = 'AlbumsRoute';
 
@@ -309,10 +274,7 @@ class AlbumsRoute extends PageRouteInfo<void> {
 /// [AllMotionPhotosPage]
 class AllMotionPhotosRoute extends PageRouteInfo<void> {
   const AllMotionPhotosRoute({List<PageRouteInfo>? children})
-      : super(
-          AllMotionPhotosRoute.name,
-          initialChildren: children,
-        );
+      : super(AllMotionPhotosRoute.name, initialChildren: children);
 
   static const String name = 'AllMotionPhotosRoute';
 
@@ -328,10 +290,7 @@ class AllMotionPhotosRoute extends PageRouteInfo<void> {
 /// [AllPeoplePage]
 class AllPeopleRoute extends PageRouteInfo<void> {
   const AllPeopleRoute({List<PageRouteInfo>? children})
-      : super(
-          AllPeopleRoute.name,
-          initialChildren: children,
-        );
+      : super(AllPeopleRoute.name, initialChildren: children);
 
   static const String name = 'AllPeopleRoute';
 
@@ -347,10 +306,7 @@ class AllPeopleRoute extends PageRouteInfo<void> {
 /// [AllPlacesPage]
 class AllPlacesRoute extends PageRouteInfo<void> {
   const AllPlacesRoute({List<PageRouteInfo>? children})
-      : super(
-          AllPlacesRoute.name,
-          initialChildren: children,
-        );
+      : super(AllPlacesRoute.name, initialChildren: children);
 
   static const String name = 'AllPlacesRoute';
 
@@ -366,10 +322,7 @@ class AllPlacesRoute extends PageRouteInfo<void> {
 /// [AllVideosPage]
 class AllVideosRoute extends PageRouteInfo<void> {
   const AllVideosRoute({List<PageRouteInfo>? children})
-      : super(
-          AllVideosRoute.name,
-          initialChildren: children,
-        );
+      : super(AllVideosRoute.name, initialChildren: children);
 
   static const String name = 'AllVideosRoute';
 
@@ -390,10 +343,7 @@ class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           AppLogDetailRoute.name,
-          args: AppLogDetailRouteArgs(
-            key: key,
-            logMessage: logMessage,
-          ),
+          args: AppLogDetailRouteArgs(key: key, logMessage: logMessage),
           initialChildren: children,
         );
 
@@ -403,19 +353,13 @@ class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<AppLogDetailRouteArgs>();
-      return AppLogDetailPage(
-        key: args.key,
-        logMessage: args.logMessage,
-      );
+      return AppLogDetailPage(key: args.key, logMessage: args.logMessage);
     },
   );
 }
 
 class AppLogDetailRouteArgs {
-  const AppLogDetailRouteArgs({
-    this.key,
-    required this.logMessage,
-  });
+  const AppLogDetailRouteArgs({this.key, required this.logMessage});
 
   final Key? key;
 
@@ -431,10 +375,7 @@ class AppLogDetailRouteArgs {
 /// [AppLogPage]
 class AppLogRoute extends PageRouteInfo<void> {
   const AppLogRoute({List<PageRouteInfo>? children})
-      : super(
-          AppLogRoute.name,
-          initialChildren: children,
-        );
+      : super(AppLogRoute.name, initialChildren: children);
 
   static const String name = 'AppLogRoute';
 
@@ -450,10 +391,7 @@ class AppLogRoute extends PageRouteInfo<void> {
 /// [ArchivePage]
 class ArchiveRoute extends PageRouteInfo<void> {
   const ArchiveRoute({List<PageRouteInfo>? children})
-      : super(
-          ArchiveRoute.name,
-          initialChildren: children,
-        );
+      : super(ArchiveRoute.name, initialChildren: children);
 
   static const String name = 'ArchiveRoute';
 
@@ -469,10 +407,7 @@ class ArchiveRoute extends PageRouteInfo<void> {
 /// [BackupAlbumSelectionPage]
 class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
   const BackupAlbumSelectionRoute({List<PageRouteInfo>? children})
-      : super(
-          BackupAlbumSelectionRoute.name,
-          initialChildren: children,
-        );
+      : super(BackupAlbumSelectionRoute.name, initialChildren: children);
 
   static const String name = 'BackupAlbumSelectionRoute';
 
@@ -488,10 +423,7 @@ class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
 /// [BackupControllerPage]
 class BackupControllerRoute extends PageRouteInfo<void> {
   const BackupControllerRoute({List<PageRouteInfo>? children})
-      : super(
-          BackupControllerRoute.name,
-          initialChildren: children,
-        );
+      : super(BackupControllerRoute.name, initialChildren: children);
 
   static const String name = 'BackupControllerRoute';
 
@@ -507,10 +439,7 @@ class BackupControllerRoute extends PageRouteInfo<void> {
 /// [BackupOptionsPage]
 class BackupOptionsRoute extends PageRouteInfo<void> {
   const BackupOptionsRoute({List<PageRouteInfo>? children})
-      : super(
-          BackupOptionsRoute.name,
-          initialChildren: children,
-        );
+      : super(BackupOptionsRoute.name, initialChildren: children);
 
   static const String name = 'BackupOptionsRoute';
 
@@ -526,10 +455,7 @@ class BackupOptionsRoute extends PageRouteInfo<void> {
 /// [ChangePasswordPage]
 class ChangePasswordRoute extends PageRouteInfo<void> {
   const ChangePasswordRoute({List<PageRouteInfo>? children})
-      : super(
-          ChangePasswordRoute.name,
-          initialChildren: children,
-        );
+      : super(ChangePasswordRoute.name, initialChildren: children);
 
   static const String name = 'ChangePasswordRoute';
 
@@ -550,10 +476,7 @@ class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           CreateAlbumRoute.name,
-          args: CreateAlbumRouteArgs(
-            key: key,
-            assets: assets,
-          ),
+          args: CreateAlbumRouteArgs(key: key, assets: assets),
           initialChildren: children,
         );
 
@@ -563,20 +486,15 @@ class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<CreateAlbumRouteArgs>(
-          orElse: () => const CreateAlbumRouteArgs());
-      return CreateAlbumPage(
-        key: args.key,
-        assets: args.assets,
+        orElse: () => const CreateAlbumRouteArgs(),
       );
+      return CreateAlbumPage(key: args.key, assets: args.assets);
     },
   );
 }
 
 class CreateAlbumRouteArgs {
-  const CreateAlbumRouteArgs({
-    this.key,
-    this.assets,
-  });
+  const CreateAlbumRouteArgs({this.key, this.assets});
 
   final Key? key;
 
@@ -598,11 +516,7 @@ class CropImageRoute extends PageRouteInfo<CropImageRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           CropImageRoute.name,
-          args: CropImageRouteArgs(
-            key: key,
-            image: image,
-            asset: asset,
-          ),
+          args: CropImageRouteArgs(key: key, image: image, asset: asset),
           initialChildren: children,
         );
 
@@ -612,11 +526,7 @@ class CropImageRoute extends PageRouteInfo<CropImageRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<CropImageRouteArgs>();
-      return CropImagePage(
-        key: args.key,
-        image: args.image,
-        asset: args.asset,
-      );
+      return CropImagePage(key: args.key, image: args.image, asset: args.asset);
     },
   );
 }
@@ -702,10 +612,7 @@ class EditImageRouteArgs {
 /// [FailedBackupStatusPage]
 class FailedBackupStatusRoute extends PageRouteInfo<void> {
   const FailedBackupStatusRoute({List<PageRouteInfo>? children})
-      : super(
-          FailedBackupStatusRoute.name,
-          initialChildren: children,
-        );
+      : super(FailedBackupStatusRoute.name, initialChildren: children);
 
   static const String name = 'FailedBackupStatusRoute';
 
@@ -721,10 +628,7 @@ class FailedBackupStatusRoute extends PageRouteInfo<void> {
 /// [FavoritesPage]
 class FavoritesRoute extends PageRouteInfo<void> {
   const FavoritesRoute({List<PageRouteInfo>? children})
-      : super(
-          FavoritesRoute.name,
-          initialChildren: children,
-        );
+      : super(FavoritesRoute.name, initialChildren: children);
 
   static const String name = 'FavoritesRoute';
 
@@ -736,6 +640,22 @@ class FavoritesRoute extends PageRouteInfo<void> {
   );
 }
 
+/// generated route for
+/// [FeatInDevPage]
+class FeatInDevRoute extends PageRouteInfo<void> {
+  const FeatInDevRoute({List<PageRouteInfo>? children})
+      : super(FeatInDevRoute.name, initialChildren: children);
+
+  static const String name = 'FeatInDevRoute';
+
+  static PageInfo page = PageInfo(
+    name,
+    builder: (data) {
+      return const FeatInDevPage();
+    },
+  );
+}
+
 /// generated route for
 /// [FilterImagePage]
 class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
@@ -746,11 +666,7 @@ class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           FilterImageRoute.name,
-          args: FilterImageRouteArgs(
-            key: key,
-            image: image,
-            asset: asset,
-          ),
+          args: FilterImageRouteArgs(key: key, image: image, asset: asset),
           initialChildren: children,
         );
 
@@ -797,10 +713,7 @@ class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           FolderRoute.name,
-          args: FolderRouteArgs(
-            key: key,
-            folder: folder,
-          ),
+          args: FolderRouteArgs(key: key, folder: folder),
           initialChildren: children,
         );
 
@@ -809,21 +722,16 @@ class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
   static PageInfo page = PageInfo(
     name,
     builder: (data) {
-      final args =
-          data.argsAs<FolderRouteArgs>(orElse: () => const FolderRouteArgs());
-      return FolderPage(
-        key: args.key,
-        folder: args.folder,
+      final args = data.argsAs<FolderRouteArgs>(
+        orElse: () => const FolderRouteArgs(),
       );
+      return FolderPage(key: args.key, folder: args.folder);
     },
   );
 }
 
 class FolderRouteArgs {
-  const FolderRouteArgs({
-    this.key,
-    this.folder,
-  });
+  const FolderRouteArgs({this.key, this.folder});
 
   final Key? key;
 
@@ -903,10 +811,7 @@ class GalleryViewerRouteArgs {
 /// [HeaderSettingsPage]
 class HeaderSettingsRoute extends PageRouteInfo<void> {
   const HeaderSettingsRoute({List<PageRouteInfo>? children})
-      : super(
-          HeaderSettingsRoute.name,
-          initialChildren: children,
-        );
+      : super(HeaderSettingsRoute.name, initialChildren: children);
 
   static const String name = 'HeaderSettingsRoute';
 
@@ -922,10 +827,7 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
 /// [LibraryPage]
 class LibraryRoute extends PageRouteInfo<void> {
   const LibraryRoute({List<PageRouteInfo>? children})
-      : super(
-          LibraryRoute.name,
-          initialChildren: children,
-        );
+      : super(LibraryRoute.name, initialChildren: children);
 
   static const String name = 'LibraryRoute';
 
@@ -941,10 +843,7 @@ class LibraryRoute extends PageRouteInfo<void> {
 /// [LocalAlbumsPage]
 class LocalAlbumsRoute extends PageRouteInfo<void> {
   const LocalAlbumsRoute({List<PageRouteInfo>? children})
-      : super(
-          LocalAlbumsRoute.name,
-          initialChildren: children,
-        );
+      : super(LocalAlbumsRoute.name, initialChildren: children);
 
   static const String name = 'LocalAlbumsRoute';
 
@@ -956,14 +855,27 @@ class LocalAlbumsRoute extends PageRouteInfo<void> {
   );
 }
 
+/// generated route for
+/// [LocalMediaSummaryPage]
+class LocalMediaSummaryRoute extends PageRouteInfo<void> {
+  const LocalMediaSummaryRoute({List<PageRouteInfo>? children})
+      : super(LocalMediaSummaryRoute.name, initialChildren: children);
+
+  static const String name = 'LocalMediaSummaryRoute';
+
+  static PageInfo page = PageInfo(
+    name,
+    builder: (data) {
+      return const LocalMediaSummaryPage();
+    },
+  );
+}
+
 /// generated route for
 /// [LockedPage]
 class LockedRoute extends PageRouteInfo<void> {
   const LockedRoute({List<PageRouteInfo>? children})
-      : super(
-          LockedRoute.name,
-          initialChildren: children,
-        );
+      : super(LockedRoute.name, initialChildren: children);
 
   static const String name = 'LockedRoute';
 
@@ -979,10 +891,7 @@ class LockedRoute extends PageRouteInfo<void> {
 /// [LoginPage]
 class LoginRoute extends PageRouteInfo<void> {
   const LoginRoute({List<PageRouteInfo>? children})
-      : super(
-          LoginRoute.name,
-          initialChildren: children,
-        );
+      : super(LoginRoute.name, initialChildren: children);
 
   static const String name = 'LoginRoute';
 
@@ -1016,7 +925,8 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<MapLocationPickerRouteArgs>(
-          orElse: () => const MapLocationPickerRouteArgs());
+        orElse: () => const MapLocationPickerRouteArgs(),
+      );
       return MapLocationPickerPage(
         key: args.key,
         initialLatLng: args.initialLatLng,
@@ -1044,16 +954,10 @@ class MapLocationPickerRouteArgs {
 /// generated route for
 /// [MapPage]
 class MapRoute extends PageRouteInfo<MapRouteArgs> {
-  MapRoute({
-    Key? key,
-    LatLng? initialLocation,
-    List<PageRouteInfo>? children,
-  }) : super(
+  MapRoute({Key? key, LatLng? initialLocation, List<PageRouteInfo>? children})
+      : super(
           MapRoute.name,
-          args: MapRouteArgs(
-            key: key,
-            initialLocation: initialLocation,
-          ),
+          args: MapRouteArgs(key: key, initialLocation: initialLocation),
           initialChildren: children,
         );
 
@@ -1062,21 +966,16 @@ class MapRoute extends PageRouteInfo<MapRouteArgs> {
   static PageInfo page = PageInfo(
     name,
     builder: (data) {
-      final args =
-          data.argsAs<MapRouteArgs>(orElse: () => const MapRouteArgs());
-      return MapPage(
-        key: args.key,
-        initialLocation: args.initialLocation,
+      final args = data.argsAs<MapRouteArgs>(
+        orElse: () => const MapRouteArgs(),
       );
+      return MapPage(key: args.key, initialLocation: args.initialLocation);
     },
   );
 }
 
 class MapRouteArgs {
-  const MapRouteArgs({
-    this.key,
-    this.initialLocation,
-  });
+  const MapRouteArgs({this.key, this.initialLocation});
 
   final Key? key;
 
@@ -1213,10 +1112,7 @@ class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           PartnerDetailRoute.name,
-          args: PartnerDetailRouteArgs(
-            key: key,
-            partner: partner,
-          ),
+          args: PartnerDetailRouteArgs(key: key, partner: partner),
           initialChildren: children,
         );
 
@@ -1226,19 +1122,13 @@ class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<PartnerDetailRouteArgs>();
-      return PartnerDetailPage(
-        key: args.key,
-        partner: args.partner,
-      );
+      return PartnerDetailPage(key: args.key, partner: args.partner);
     },
   );
 }
 
 class PartnerDetailRouteArgs {
-  const PartnerDetailRouteArgs({
-    this.key,
-    required this.partner,
-  });
+  const PartnerDetailRouteArgs({this.key, required this.partner});
 
   final Key? key;
 
@@ -1254,10 +1144,7 @@ class PartnerDetailRouteArgs {
 /// [PartnerPage]
 class PartnerRoute extends PageRouteInfo<void> {
   const PartnerRoute({List<PageRouteInfo>? children})
-      : super(
-          PartnerRoute.name,
-          initialChildren: children,
-        );
+      : super(PartnerRoute.name, initialChildren: children);
 
   static const String name = 'PartnerRoute';
 
@@ -1273,10 +1160,7 @@ class PartnerRoute extends PageRouteInfo<void> {
 /// [PeopleCollectionPage]
 class PeopleCollectionRoute extends PageRouteInfo<void> {
   const PeopleCollectionRoute({List<PageRouteInfo>? children})
-      : super(
-          PeopleCollectionRoute.name,
-          initialChildren: children,
-        );
+      : super(PeopleCollectionRoute.name, initialChildren: children);
 
   static const String name = 'PeopleCollectionRoute';
 
@@ -1292,10 +1176,7 @@ class PeopleCollectionRoute extends PageRouteInfo<void> {
 /// [PermissionOnboardingPage]
 class PermissionOnboardingRoute extends PageRouteInfo<void> {
   const PermissionOnboardingRoute({List<PageRouteInfo>? children})
-      : super(
-          PermissionOnboardingRoute.name,
-          initialChildren: children,
-        );
+      : super(PermissionOnboardingRoute.name, initialChildren: children);
 
   static const String name = 'PermissionOnboardingRoute';
 
@@ -1363,10 +1244,7 @@ class PersonResultRouteArgs {
 /// [PhotosPage]
 class PhotosRoute extends PageRouteInfo<void> {
   const PhotosRoute({List<PageRouteInfo>? children})
-      : super(
-          PhotosRoute.name,
-          initialChildren: children,
-        );
+      : super(PhotosRoute.name, initialChildren: children);
 
   static const String name = 'PhotosRoute';
 
@@ -1387,10 +1265,7 @@ class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           PinAuthRoute.name,
-          args: PinAuthRouteArgs(
-            key: key,
-            createPinCode: createPinCode,
-          ),
+          args: PinAuthRouteArgs(key: key, createPinCode: createPinCode),
           initialChildren: children,
         );
 
@@ -1399,21 +1274,16 @@ class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
   static PageInfo page = PageInfo(
     name,
     builder: (data) {
-      final args =
-          data.argsAs<PinAuthRouteArgs>(orElse: () => const PinAuthRouteArgs());
-      return PinAuthPage(
-        key: args.key,
-        createPinCode: args.createPinCode,
+      final args = data.argsAs<PinAuthRouteArgs>(
+        orElse: () => const PinAuthRouteArgs(),
       );
+      return PinAuthPage(key: args.key, createPinCode: args.createPinCode);
     },
   );
 }
 
 class PinAuthRouteArgs {
-  const PinAuthRouteArgs({
-    this.key,
-    this.createPinCode = false,
-  });
+  const PinAuthRouteArgs({this.key, this.createPinCode = false});
 
   final Key? key;
 
@@ -1447,7 +1317,8 @@ class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<PlacesCollectionRouteArgs>(
-          orElse: () => const PlacesCollectionRouteArgs());
+        orElse: () => const PlacesCollectionRouteArgs(),
+      );
       return PlacesCollectionPage(
         key: args.key,
         currentLocation: args.currentLocation,
@@ -1457,10 +1328,7 @@ class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
 }
 
 class PlacesCollectionRouteArgs {
-  const PlacesCollectionRouteArgs({
-    this.key,
-    this.currentLocation,
-  });
+  const PlacesCollectionRouteArgs({this.key, this.currentLocation});
 
   final Key? key;
 
@@ -1476,10 +1344,7 @@ class PlacesCollectionRouteArgs {
 /// [RecentlyTakenPage]
 class RecentlyTakenRoute extends PageRouteInfo<void> {
   const RecentlyTakenRoute({List<PageRouteInfo>? children})
-      : super(
-          RecentlyTakenRoute.name,
-          initialChildren: children,
-        );
+      : super(RecentlyTakenRoute.name, initialChildren: children);
 
   static const String name = 'RecentlyTakenRoute';
 
@@ -1500,10 +1365,7 @@ class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           SearchRoute.name,
-          args: SearchRouteArgs(
-            key: key,
-            prefilter: prefilter,
-          ),
+          args: SearchRouteArgs(key: key, prefilter: prefilter),
           initialChildren: children,
         );
 
@@ -1512,21 +1374,16 @@ class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
   static PageInfo page = PageInfo(
     name,
     builder: (data) {
-      final args =
-          data.argsAs<SearchRouteArgs>(orElse: () => const SearchRouteArgs());
-      return SearchPage(
-        key: args.key,
-        prefilter: args.prefilter,
+      final args = data.argsAs<SearchRouteArgs>(
+        orElse: () => const SearchRouteArgs(),
       );
+      return SearchPage(key: args.key, prefilter: args.prefilter);
     },
   );
 }
 
 class SearchRouteArgs {
-  const SearchRouteArgs({
-    this.key,
-    this.prefilter,
-  });
+  const SearchRouteArgs({this.key, this.prefilter});
 
   final Key? key;
 
@@ -1542,10 +1399,7 @@ class SearchRouteArgs {
 /// [SettingsPage]
 class SettingsRoute extends PageRouteInfo<void> {
   const SettingsRoute({List<PageRouteInfo>? children})
-      : super(
-          SettingsRoute.name,
-          initialChildren: children,
-        );
+      : super(SettingsRoute.name, initialChildren: children);
 
   static const String name = 'SettingsRoute';
 
@@ -1566,10 +1420,7 @@ class SettingsSubRoute extends PageRouteInfo<SettingsSubRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           SettingsSubRoute.name,
-          args: SettingsSubRouteArgs(
-            section: section,
-            key: key,
-          ),
+          args: SettingsSubRouteArgs(section: section, key: key),
           initialChildren: children,
         );
 
@@ -1579,19 +1430,13 @@ class SettingsSubRoute extends PageRouteInfo<SettingsSubRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<SettingsSubRouteArgs>();
-      return SettingsSubPage(
-        args.section,
-        key: args.key,
-      );
+      return SettingsSubPage(args.section, key: args.key);
     },
   );
 }
 
 class SettingsSubRouteArgs {
-  const SettingsSubRouteArgs({
-    required this.section,
-    this.key,
-  });
+  const SettingsSubRouteArgs({required this.section, this.key});
 
   final SettingSection section;
 
@@ -1612,10 +1457,7 @@ class ShareIntentRoute extends PageRouteInfo<ShareIntentRouteArgs> {
     List<PageRouteInfo>? children,
   }) : super(
           ShareIntentRoute.name,
-          args: ShareIntentRouteArgs(
-            key: key,
-            attachments: attachments,
-          ),
+          args: ShareIntentRouteArgs(key: key, attachments: attachments),
           initialChildren: children,
         );
 
@@ -1625,19 +1467,13 @@ class ShareIntentRoute extends PageRouteInfo<ShareIntentRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<ShareIntentRouteArgs>();
-      return ShareIntentPage(
-        key: args.key,
-        attachments: args.attachments,
-      );
+      return ShareIntentPage(key: args.key, attachments: args.attachments);
     },
   );
 }
 
 class ShareIntentRouteArgs {
-  const ShareIntentRouteArgs({
-    this.key,
-    required this.attachments,
-  });
+  const ShareIntentRouteArgs({this.key, required this.attachments});
 
   final Key? key;
 
@@ -1675,7 +1511,8 @@ class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
     name,
     builder: (data) {
       final args = data.argsAs<SharedLinkEditRouteArgs>(
-          orElse: () => const SharedLinkEditRouteArgs());
+        orElse: () => const SharedLinkEditRouteArgs(),
+      );
       return SharedLinkEditPage(
         key: args.key,
         existingLink: args.existingLink,
@@ -1712,10 +1549,7 @@ class SharedLinkEditRouteArgs {
 /// [SharedLinkPage]
 class SharedLinkRoute extends PageRouteInfo<void> {
   const SharedLinkRoute({List<PageRouteInfo>? children})
-      : super(
-          SharedLinkRoute.name,
-          initialChildren: children,
-        );
+      : super(SharedLinkRoute.name, initialChildren: children);
 
   static const String name = 'SharedLinkRoute';
 
@@ -1731,10 +1565,7 @@ class SharedLinkRoute extends PageRouteInfo<void> {
 /// [SplashScreenPage]
 class SplashScreenRoute extends PageRouteInfo<void> {
   const SplashScreenRoute({List<PageRouteInfo>? children})
-      : super(
-          SplashScreenRoute.name,
-          initialChildren: children,
-        );
+      : super(SplashScreenRoute.name, initialChildren: children);
 
   static const String name = 'SplashScreenRoute';
 
@@ -1750,10 +1581,7 @@ class SplashScreenRoute extends PageRouteInfo<void> {
 /// [TabControllerPage]
 class TabControllerRoute extends PageRouteInfo<void> {
   const TabControllerRoute({List<PageRouteInfo>? children})
-      : super(
-          TabControllerRoute.name,
-          initialChildren: children,
-        );
+      : super(TabControllerRoute.name, initialChildren: children);
 
   static const String name = 'TabControllerRoute';
 
@@ -1769,10 +1597,7 @@ class TabControllerRoute extends PageRouteInfo<void> {
 /// [TrashPage]
 class TrashRoute extends PageRouteInfo<void> {
   const TrashRoute({List<PageRouteInfo>? children})
-      : super(
-          TrashRoute.name,
-          initialChildren: children,
-        );
+      : super(TrashRoute.name, initialChildren: children);
 
   static const String name = 'TrashRoute';
 
diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart
index 4f95e657d9..09f81b9e1a 100644
--- a/mobile/lib/widgets/common/immich_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_app_bar.dart
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/models/backup/backup_state.model.dart';
 import 'package:immich_mobile/models/server_info/server_info.model.dart';
-import 'package:immich_mobile/providers/background_sync.provider.dart';
 import 'package:immich_mobile/providers/backup/backup.provider.dart';
 import 'package:immich_mobile/providers/server_info.provider.dart';
 import 'package:immich_mobile/providers/user.provider.dart';
@@ -180,10 +179,10 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
               child: action,
             ),
           ),
-        if (kDebugMode)
+        if (kDebugMode || kProfileMode)
           IconButton(
-            onPressed: () => ref.read(backgroundSyncProvider).sync(),
-            icon: const Icon(Icons.sync),
+            icon: const Icon(Icons.science_rounded),
+            onPressed: () => context.pushRoute(const FeatInDevRoute()),
           ),
         if (showUploadButton)
           Padding(
diff --git a/mobile/makefile b/mobile/makefile
index b0083b1495..b797a65928 100644
--- a/mobile/makefile
+++ b/mobile/makefile
@@ -1,7 +1,13 @@
-.PHONY: build watch create_app_icon create_splash build_release_android
+.PHONY: build watch create_app_icon create_splash build_release_android pigeon
 
 build:
 	dart run build_runner build --delete-conflicting-outputs
+# Remove once auto_route updated to 10.1.0
+	dart format lib/routing/router.gr.dart
+
+pigeon:
+	dart run pigeon --input pigeon/native_sync_api.dart
+	dart format lib/platform/native_sync_api.g.dart
 
 watch:
 	dart run build_runner watch --delete-conflicting-outputs
@@ -19,4 +25,5 @@ migrations:
 	dart run drift_dev make-migrations
 
 translation:
-	dart run easy_localization:generate -S ../i18n 
\ No newline at end of file
+	dart run easy_localization:generate -S ../i18n
+	dart format lib/generated/codegen_loader.g.dart
\ No newline at end of file
diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart
new file mode 100644
index 0000000000..b8a7500d6e
--- /dev/null
+++ b/mobile/pigeon/native_sync_api.dart
@@ -0,0 +1,89 @@
+import 'package:pigeon/pigeon.dart';
+
+@ConfigurePigeon(
+  PigeonOptions(
+    dartOut: 'lib/platform/native_sync_api.g.dart',
+    swiftOut: 'ios/Runner/Sync/Messages.g.swift',
+    swiftOptions: SwiftOptions(),
+    kotlinOut:
+        'android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt',
+    kotlinOptions: KotlinOptions(package: 'app.alextran.immich.sync'),
+    dartOptions: DartOptions(),
+    dartPackageName: 'immich_mobile',
+  ),
+)
+class PlatformAsset {
+  final String id;
+  final String name;
+  // Follows AssetType enum from base_asset.model.dart
+  final int type;
+  // Seconds since epoch
+  final int? createdAt;
+  final int? updatedAt;
+  final int durationInSeconds;
+
+  const PlatformAsset({
+    required this.id,
+    required this.name,
+    required this.type,
+    this.createdAt,
+    this.updatedAt,
+    this.durationInSeconds = 0,
+  });
+}
+
+class PlatformAlbum {
+  final String id;
+  final String name;
+  // Seconds since epoch
+  final int? updatedAt;
+  final bool isCloud;
+  final int assetCount;
+
+  const PlatformAlbum({
+    required this.id,
+    required this.name,
+    this.updatedAt,
+    this.isCloud = false,
+    this.assetCount = 0,
+  });
+}
+
+class SyncDelta {
+  final bool hasChanges;
+  final List<PlatformAsset> updates;
+  final List<String> deletes;
+  // Asset -> Album mapping
+  final Map<String, List<String>> assetAlbums;
+
+  const SyncDelta({
+    this.hasChanges = false,
+    this.updates = const [],
+    this.deletes = const [],
+    this.assetAlbums = const {},
+  });
+}
+
+@HostApi()
+abstract class NativeSyncApi {
+  bool shouldFullSync();
+
+  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
+  SyncDelta getMediaChanges();
+
+  void checkpointSync();
+
+  void clearSyncCheckpoint();
+
+  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
+  List<String> getAssetIdsForAlbum(String albumId);
+
+  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
+  List<PlatformAlbum> getAlbums();
+
+  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
+  int getAssetsCountSince(String albumId, int timestamp);
+
+  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
+  List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 3df4e4e8a9..5c54a2c349 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -5,31 +5,26 @@ packages:
     dependency: transitive
     description:
       name: _fe_analyzer_shared
-      sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
+      sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
       url: "https://pub.dev"
     source: hosted
-    version: "76.0.0"
-  _macros:
-    dependency: transitive
-    description: dart
-    source: sdk
-    version: "0.3.3"
+    version: "80.0.0"
   analyzer:
-    dependency: "direct overridden"
+    dependency: transitive
     description:
       name: analyzer
-      sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
+      sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
       url: "https://pub.dev"
     source: hosted
-    version: "6.11.0"
+    version: "7.3.0"
   analyzer_plugin:
-    dependency: "direct overridden"
+    dependency: transitive
     description:
       name: analyzer_plugin
-      sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
+      sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
       url: "https://pub.dev"
     source: hosted
-    version: "0.11.3"
+    version: "0.13.0"
   ansicolor:
     dependency: transitive
     description:
@@ -74,10 +69,10 @@ packages:
     dependency: "direct dev"
     description:
       name: auto_route_generator
-      sha256: c9086eb07271e51b44071ad5cff34e889f3156710b964a308c2ab590769e79e6
+      sha256: c2e359d8932986d4d1bcad7a428143f81384ce10fef8d4aa5bc29e1f83766a46
       url: "https://pub.dev"
     source: hosted
-    version: "9.0.0"
+    version: "9.3.1"
   background_downloader:
     dependency: "direct main"
     description:
@@ -322,34 +317,42 @@ packages:
     dependency: "direct dev"
     description:
       name: custom_lint
-      sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545"
+      sha256: "409c485fd14f544af1da965d5a0d160ee57cd58b63eeaa7280a4f28cf5bda7f1"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
   custom_lint_builder:
     dependency: transitive
     description:
       name: custom_lint_builder
-      sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78"
+      sha256: "107e0a43606138015777590ee8ce32f26ba7415c25b722ff0908a6f5d7a4c228"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
   custom_lint_core:
     dependency: transitive
     description:
       name: custom_lint_core
-      sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6"
+      sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.10"
+    version: "0.7.5"
+  custom_lint_visitor:
+    dependency: transitive
+    description:
+      name: custom_lint_visitor
+      sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0+7.3.0"
   dart_style:
     dependency: transitive
     description:
       name: dart_style
-      sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820"
+      sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
       url: "https://pub.dev"
     source: hosted
-    version: "2.3.8"
+    version: "3.1.0"
   dartx:
     dependency: transitive
     description:
@@ -723,10 +726,10 @@ packages:
     dependency: transitive
     description:
       name: freezed_annotation
-      sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
+      sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
       url: "https://pub.dev"
     source: hosted
-    version: "2.4.4"
+    version: "3.0.0"
   frontend_server_client:
     dependency: transitive
     description:
@@ -971,10 +974,11 @@ packages:
   isar_generator:
     dependency: "direct dev"
     description:
-      name: isar_generator
-      sha256: "484e73d3b7e81dbd816852fe0b9497333118a9aeb646fd2d349a62cc8980ffe1"
-      url: "https://pub.isar-community.dev"
-    source: hosted
+      path: "packages/isar_generator"
+      ref: v3
+      resolved-ref: ad574f60ed6f39d2995cd16fc7dc3de9a646ef30
+      url: "https://github.com/immich-app/isar"
+    source: git
     version: "3.1.8"
   js:
     dependency: transitive
@@ -1072,14 +1076,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.0"
-  macros:
-    dependency: transitive
-    description:
-      name: macros
-      sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
-      url: "https://pub.dev"
-    source: hosted
-    version: "0.1.3-main.0"
   maplibre_gl:
     dependency: "direct main"
     description:
@@ -1121,7 +1117,7 @@ packages:
     source: hosted
     version: "0.11.1"
   meta:
-    dependency: "direct overridden"
+    dependency: transitive
     description:
       name: meta
       sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
@@ -1352,6 +1348,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.2.0"
+  pigeon:
+    dependency: "direct dev"
+    description:
+      name: pigeon
+      sha256: a093af76026160bb5ff6eb98e3e678a301ffd1001ac0d90be558bc133a0c73f5
+      url: "https://pub.dev"
+    source: hosted
+    version: "25.3.2"
   pinput:
     dependency: "direct main"
     description:
@@ -1361,7 +1365,7 @@ packages:
     source: hosted
     version: "5.0.1"
   platform:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: platform
       sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
@@ -1444,10 +1448,10 @@ packages:
     dependency: transitive
     description:
       name: riverpod_analyzer_utils
-      sha256: "0dcb0af32d561f8fa000c6a6d95633c9fb08ea8a8df46e3f9daca59f11218167"
+      sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
       url: "https://pub.dev"
     source: hosted
-    version: "0.5.6"
+    version: "0.5.10"
   riverpod_annotation:
     dependency: "direct main"
     description:
@@ -1460,18 +1464,18 @@ packages:
     dependency: "direct dev"
     description:
       name: riverpod_generator
-      sha256: "851aedac7ad52693d12af3bf6d92b1626d516ed6b764eb61bf19e968b5e0b931"
+      sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
       url: "https://pub.dev"
     source: hosted
-    version: "2.6.1"
+    version: "2.6.5"
   riverpod_lint:
     dependency: "direct dev"
     description:
       name: riverpod_lint
-      sha256: "0684c21a9a4582c28c897d55c7b611fa59a351579061b43f8c92c005804e63a8"
+      sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
       url: "https://pub.dev"
     source: hosted
-    version: "2.6.1"
+    version: "2.6.5"
   rxdart:
     dependency: transitive
     description:
@@ -1633,10 +1637,10 @@ packages:
     dependency: transitive
     description:
       name: source_gen
-      sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
+      sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
       url: "https://pub.dev"
     source: hosted
-    version: "1.5.0"
+    version: "2.0.0"
   source_span:
     dependency: transitive
     description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 6dd81b7fc1..81249fdcfa 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -32,6 +32,7 @@ dependencies:
   flutter_displaymode: ^0.6.0
   flutter_hooks: ^0.21.2
   flutter_local_notifications: ^17.2.1+2
+  flutter_secure_storage: ^9.2.4
   flutter_svg: ^2.0.17
   flutter_udid: ^3.0.0
   flutter_web_auth_2: ^5.0.0-alpha.0
@@ -41,6 +42,7 @@ dependencies:
   http: ^1.3.0
   image_picker: ^1.1.2
   intl: ^0.19.0
+  local_auth: ^2.3.0
   logging: ^1.3.0
   maplibre_gl: ^0.21.0
   network_info_plus: ^6.1.3
@@ -52,6 +54,8 @@ dependencies:
   permission_handler: ^11.4.0
   photo_manager: ^3.6.4
   photo_manager_image_provider: ^2.2.0
+  pinput: ^5.0.1
+  platform: ^3.1.6
   punycode: ^1.0.0
   riverpod_annotation: ^2.6.1
   scrollable_positioned_list: ^0.3.8
@@ -64,9 +68,6 @@ dependencies:
   uuid: ^4.5.1
   wakelock_plus: ^1.2.10
   worker_manager: ^7.2.3
-  local_auth: ^2.3.0
-  pinput: ^5.0.1
-  flutter_secure_storage: ^9.2.4
 
   native_video_player:
     git:
@@ -84,11 +85,6 @@ dependencies:
   drift: ^2.23.1
   drift_flutter: ^0.2.4
 
-dependency_overrides:
-  analyzer: ^6.0.0
-  meta: ^1.11.0
-  analyzer_plugin: ^0.11.3
-
 dev_dependencies:
   flutter_test:
     sdk: flutter
@@ -98,11 +94,13 @@ dev_dependencies:
   flutter_launcher_icons: ^0.14.3
   flutter_native_splash: ^2.4.5
   isar_generator:
-    version: *isar_version
-    hosted: https://pub.isar-community.dev/
+    git:
+      url: https://github.com/immich-app/isar
+      ref: v3
+      path: packages/isar_generator/
   integration_test:
     sdk: flutter
-  custom_lint: ^0.6.4
+  custom_lint: ^0.7.5
   riverpod_lint: ^2.6.1
   riverpod_generator: ^2.6.1
   mocktail: ^1.0.4
@@ -112,6 +110,8 @@ dev_dependencies:
   file: ^7.0.1 # for MemoryFileSystem
   # Drift generator
   drift_dev: ^2.23.1
+  # Type safe platform code
+  pigeon: ^25.3.1
 
 flutter:
   uses-material-design: true