mirror of
https://github.com/immich-app/immich.git
synced 2026-03-23 09:29:34 +03:00
background upload plugin
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up
This commit is contained in:
@@ -105,6 +105,7 @@ dependencies {
|
||||
def serialization_version = '1.8.1'
|
||||
def compose_version = '1.1.1'
|
||||
def gson_version = '2.10.1'
|
||||
def room_version = "2.8.3"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
@@ -113,6 +114,8 @@ dependencies {
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2"
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.1"
|
||||
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
@@ -127,6 +130,10 @@ dependencies {
|
||||
implementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
implementation "androidx.compose.material3:material3:1.2.1"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
|
||||
|
||||
// Room Database
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
}
|
||||
|
||||
// This is uncommented in F-Droid build script
|
||||
|
||||
@@ -7,11 +7,13 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.upload.NetworkMonitor
|
||||
|
||||
class ImmichApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val config = Configuration.Builder().build()
|
||||
NetworkMonitor.initialize(this)
|
||||
WorkManager.initialize(this, config)
|
||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||
|
||||
@@ -15,6 +15,8 @@ import app.alextran.immich.images.ThumbnailsImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.upload.UploadApi
|
||||
import app.alextran.immich.upload.UploadTaskImpl
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -39,6 +41,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
UploadApi.setUp(messenger, UploadTaskImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
class Converters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it * 1000) }
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? = date?.let { it.time / 1000 }
|
||||
|
||||
@TypeConverter
|
||||
fun fromUrl(value: String?): URL? = value?.let { URL(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun urlToString(url: URL?): String? = url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun fromStoreKey(value: Int?): StoreKey? = value?.let { StoreKey.fromInt(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun storeKeyToInt(storeKey: StoreKey?): Int? = storeKey?.rawValue
|
||||
|
||||
@TypeConverter
|
||||
fun fromTaskStatus(value: Int?): TaskStatus? = value?.let { TaskStatus.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun taskStatusToInt(status: TaskStatus?): Int? = status?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromBackupSelection(value: Int?): BackupSelection? = value?.let { BackupSelection.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun backupSelectionToInt(selection: BackupSelection?): Int? = selection?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAvatarColor(value: Int?): AvatarColor? = value?.let { AvatarColor.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun avatarColorToInt(color: AvatarColor?): Int? = color?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAlbumUserRole(value: Int?): AlbumUserRole? = value?.let { AlbumUserRole.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun albumUserRoleToInt(role: AlbumUserRole?): Int? = role?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromMemoryType(value: Int?): MemoryType? = value?.let { MemoryType.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun memoryTypeToInt(type: MemoryType?): Int? = type?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAssetVisibility(value: Int?): AssetVisibility? = value?.let { AssetVisibility.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun assetVisibilityToInt(visibility: AssetVisibility?): Int? = visibility?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromSourceType(value: String?): SourceType? = value?.let { SourceType.fromString(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun sourceTypeToString(type: SourceType?): String? = type?.value
|
||||
|
||||
@TypeConverter
|
||||
fun fromUploadMethod(value: Int?): UploadMethod? = value?.let { UploadMethod.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun uploadMethodToInt(method: UploadMethod?): Int? = method?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromUploadErrorCode(value: Int?): UploadErrorCode? = value?.let { UploadErrorCode.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun uploadErrorCodeToInt(code: UploadErrorCode?): Int? = code?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromAssetType(value: Int?): AssetType? = value?.let { AssetType.entries[it] }
|
||||
|
||||
@TypeConverter
|
||||
fun assetTypeToInt(type: AssetType?): Int? = type?.ordinal
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringMap(value: String?): Map<String, String>? {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
return gson.fromJson(value, type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun stringMapToString(map: Map<String, String>?): String? = gson.toJson(map)
|
||||
|
||||
@TypeConverter
|
||||
fun fromEndpointStatus(value: String?): EndpointStatus? = value?.let { EndpointStatus.fromString(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun endpointStatusToString(status: EndpointStatus?): String? = status?.value
|
||||
|
||||
@TypeConverter
|
||||
fun fromEndpointList(value: String?): List<Endpoint>? {
|
||||
val type = object : TypeToken<List<Endpoint>>() {}.type
|
||||
return gson.fromJson(value, type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun endpointListToString(list: List<Endpoint>?): String? = gson.toJson(list)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
AssetFace::class,
|
||||
AuthUser::class,
|
||||
LocalAlbum::class,
|
||||
LocalAlbumAsset::class,
|
||||
LocalAsset::class,
|
||||
MemoryAsset::class,
|
||||
Memory::class,
|
||||
Partner::class,
|
||||
Person::class,
|
||||
RemoteAlbum::class,
|
||||
RemoteAlbumAsset::class,
|
||||
RemoteAlbumUser::class,
|
||||
RemoteAsset::class,
|
||||
RemoteExif::class,
|
||||
Stack::class,
|
||||
Store::class,
|
||||
UploadTask::class,
|
||||
UploadTaskStat::class,
|
||||
User::class,
|
||||
UserMetadata::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun localAssetDao(): LocalAssetDao
|
||||
abstract fun storeDao(): StoreDao
|
||||
abstract fun uploadTaskDao(): UploadTaskDao
|
||||
abstract fun uploadTaskStatDao(): UploadTaskStatDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"app_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
enum class StoreKey(val rawValue: Int) {
|
||||
VERSION(0),
|
||||
DEVICE_ID_HASH(3),
|
||||
BACKUP_TRIGGER_DELAY(8),
|
||||
TILES_PER_ROW(103),
|
||||
GROUP_ASSETS_BY(105),
|
||||
UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD(106),
|
||||
THUMBNAIL_CACHE_SIZE(110),
|
||||
IMAGE_CACHE_SIZE(111),
|
||||
ALBUM_THUMBNAIL_CACHE_SIZE(112),
|
||||
SELECTED_ALBUM_SORT_ORDER(113),
|
||||
LOG_LEVEL(115),
|
||||
MAP_RELATIVE_DATE(119),
|
||||
MAP_THEME_MODE(124),
|
||||
|
||||
ASSET_ETAG(1),
|
||||
CURRENT_USER(2),
|
||||
DEVICE_ID(4),
|
||||
ACCESS_TOKEN(11),
|
||||
SERVER_ENDPOINT(12),
|
||||
SSL_CLIENT_CERT_DATA(15),
|
||||
SSL_CLIENT_PASSWD(16),
|
||||
THEME_MODE(102),
|
||||
CUSTOM_HEADERS(127),
|
||||
PRIMARY_COLOR(128),
|
||||
PREFERRED_WIFI_NAME(133),
|
||||
|
||||
EXTERNAL_ENDPOINT_LIST(135),
|
||||
|
||||
LOCAL_ENDPOINT(134),
|
||||
SERVER_URL(10),
|
||||
|
||||
BACKUP_FAILED_SINCE(5),
|
||||
|
||||
BACKUP_REQUIRE_WIFI(6),
|
||||
BACKUP_REQUIRE_CHARGING(7),
|
||||
AUTO_BACKUP(13),
|
||||
BACKGROUND_BACKUP(14),
|
||||
LOAD_PREVIEW(100),
|
||||
LOAD_ORIGINAL(101),
|
||||
DYNAMIC_LAYOUT(104),
|
||||
BACKGROUND_BACKUP_TOTAL_PROGRESS(107),
|
||||
BACKGROUND_BACKUP_SINGLE_PROGRESS(108),
|
||||
STORAGE_INDICATOR(109),
|
||||
ADVANCED_TROUBLESHOOTING(114),
|
||||
PREFER_REMOTE_IMAGE(116),
|
||||
LOOP_VIDEO(117),
|
||||
MAP_SHOW_FAVORITE_ONLY(118),
|
||||
SELF_SIGNED_CERT(120),
|
||||
MAP_INCLUDE_ARCHIVED(121),
|
||||
IGNORE_ICLOUD_ASSETS(122),
|
||||
SELECTED_ALBUM_SORT_REVERSE(123),
|
||||
MAP_WITH_PARTNERS(125),
|
||||
ENABLE_HAPTIC_FEEDBACK(126),
|
||||
DYNAMIC_THEME(129),
|
||||
COLORFUL_INTERFACE(130),
|
||||
SYNC_ALBUMS(131),
|
||||
AUTO_ENDPOINT_SWITCHING(132),
|
||||
LOAD_ORIGINAL_VIDEO(136),
|
||||
MANAGE_LOCAL_MEDIA_ANDROID(137),
|
||||
READONLY_MODE_ENABLED(138),
|
||||
AUTO_PLAY_VIDEO(139),
|
||||
PHOTO_MANAGER_CUSTOM_FILTER(1000),
|
||||
BETA_PROMPT_SHOWN(1001),
|
||||
BETA_TIMELINE(1002),
|
||||
ENABLE_BACKUP(1003),
|
||||
USE_WIFI_FOR_UPLOAD_VIDEOS(1004),
|
||||
USE_WIFI_FOR_UPLOAD_PHOTOS(1005),
|
||||
NEED_BETA_MIGRATION(1006),
|
||||
SHOULD_RESET_SYNC(1007);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): StoreKey? = entries.find { it.rawValue == value }
|
||||
|
||||
// Int keys
|
||||
val version = TypedStoreKey<Int>(VERSION)
|
||||
val deviceIdHash = TypedStoreKey<Int>(DEVICE_ID_HASH)
|
||||
val backupTriggerDelay = TypedStoreKey<Int>(BACKUP_TRIGGER_DELAY)
|
||||
val tilesPerRow = TypedStoreKey<Int>(TILES_PER_ROW)
|
||||
val groupAssetsBy = TypedStoreKey<Int>(GROUP_ASSETS_BY)
|
||||
val uploadErrorNotificationGracePeriod = TypedStoreKey<Int>(UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD)
|
||||
val thumbnailCacheSize = TypedStoreKey<Int>(THUMBNAIL_CACHE_SIZE)
|
||||
val imageCacheSize = TypedStoreKey<Int>(IMAGE_CACHE_SIZE)
|
||||
val albumThumbnailCacheSize = TypedStoreKey<Int>(ALBUM_THUMBNAIL_CACHE_SIZE)
|
||||
val selectedAlbumSortOrder = TypedStoreKey<Int>(SELECTED_ALBUM_SORT_ORDER)
|
||||
val logLevel = TypedStoreKey<Int>(LOG_LEVEL)
|
||||
val mapRelativeDate = TypedStoreKey<Int>(MAP_RELATIVE_DATE)
|
||||
val mapThemeMode = TypedStoreKey<Int>(MAP_THEME_MODE)
|
||||
|
||||
// String keys
|
||||
val assetETag = TypedStoreKey<String>(ASSET_ETAG)
|
||||
val currentUser = TypedStoreKey<String>(CURRENT_USER)
|
||||
val deviceId = TypedStoreKey<String>(DEVICE_ID)
|
||||
val accessToken = TypedStoreKey<String>(ACCESS_TOKEN)
|
||||
val sslClientCertData = TypedStoreKey<String>(SSL_CLIENT_CERT_DATA)
|
||||
val sslClientPasswd = TypedStoreKey<String>(SSL_CLIENT_PASSWD)
|
||||
val themeMode = TypedStoreKey<String>(THEME_MODE)
|
||||
val customHeaders = TypedStoreKey<Map<String, String>>(CUSTOM_HEADERS)
|
||||
val primaryColor = TypedStoreKey<String>(PRIMARY_COLOR)
|
||||
val preferredWifiName = TypedStoreKey<String>(PREFERRED_WIFI_NAME)
|
||||
|
||||
// Endpoint keys
|
||||
val externalEndpointList = TypedStoreKey<List<Endpoint>>(EXTERNAL_ENDPOINT_LIST)
|
||||
|
||||
// URL keys
|
||||
val localEndpoint = TypedStoreKey<URL>(LOCAL_ENDPOINT)
|
||||
val serverEndpoint = TypedStoreKey<URL>(SERVER_ENDPOINT)
|
||||
val serverUrl = TypedStoreKey<URL>(SERVER_URL)
|
||||
|
||||
// Date keys
|
||||
val backupFailedSince = TypedStoreKey<Date>(BACKUP_FAILED_SINCE)
|
||||
|
||||
// Bool keys
|
||||
val backupRequireWifi = TypedStoreKey<Boolean>(BACKUP_REQUIRE_WIFI)
|
||||
val backupRequireCharging = TypedStoreKey<Boolean>(BACKUP_REQUIRE_CHARGING)
|
||||
val autoBackup = TypedStoreKey<Boolean>(AUTO_BACKUP)
|
||||
val backgroundBackup = TypedStoreKey<Boolean>(BACKGROUND_BACKUP)
|
||||
val loadPreview = TypedStoreKey<Boolean>(LOAD_PREVIEW)
|
||||
val loadOriginal = TypedStoreKey<Boolean>(LOAD_ORIGINAL)
|
||||
val dynamicLayout = TypedStoreKey<Boolean>(DYNAMIC_LAYOUT)
|
||||
val backgroundBackupTotalProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_TOTAL_PROGRESS)
|
||||
val backgroundBackupSingleProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_SINGLE_PROGRESS)
|
||||
val storageIndicator = TypedStoreKey<Boolean>(STORAGE_INDICATOR)
|
||||
val advancedTroubleshooting = TypedStoreKey<Boolean>(ADVANCED_TROUBLESHOOTING)
|
||||
val preferRemoteImage = TypedStoreKey<Boolean>(PREFER_REMOTE_IMAGE)
|
||||
val loopVideo = TypedStoreKey<Boolean>(LOOP_VIDEO)
|
||||
val mapShowFavoriteOnly = TypedStoreKey<Boolean>(MAP_SHOW_FAVORITE_ONLY)
|
||||
val selfSignedCert = TypedStoreKey<Boolean>(SELF_SIGNED_CERT)
|
||||
val mapIncludeArchived = TypedStoreKey<Boolean>(MAP_INCLUDE_ARCHIVED)
|
||||
val ignoreIcloudAssets = TypedStoreKey<Boolean>(IGNORE_ICLOUD_ASSETS)
|
||||
val selectedAlbumSortReverse = TypedStoreKey<Boolean>(SELECTED_ALBUM_SORT_REVERSE)
|
||||
val mapwithPartners = TypedStoreKey<Boolean>(MAP_WITH_PARTNERS)
|
||||
val enableHapticFeedback = TypedStoreKey<Boolean>(ENABLE_HAPTIC_FEEDBACK)
|
||||
val dynamicTheme = TypedStoreKey<Boolean>(DYNAMIC_THEME)
|
||||
val colorfulInterface = TypedStoreKey<Boolean>(COLORFUL_INTERFACE)
|
||||
val syncAlbums = TypedStoreKey<Boolean>(SYNC_ALBUMS)
|
||||
val autoEndpointSwitching = TypedStoreKey<Boolean>(AUTO_ENDPOINT_SWITCHING)
|
||||
val loadOriginalVideo = TypedStoreKey<Boolean>(LOAD_ORIGINAL_VIDEO)
|
||||
val manageLocalMediaAndroid = TypedStoreKey<Boolean>(MANAGE_LOCAL_MEDIA_ANDROID)
|
||||
val readonlyModeEnabled = TypedStoreKey<Boolean>(READONLY_MODE_ENABLED)
|
||||
val autoPlayVideo = TypedStoreKey<Boolean>(AUTO_PLAY_VIDEO)
|
||||
val photoManagerCustomFilter = TypedStoreKey<Boolean>(PHOTO_MANAGER_CUSTOM_FILTER)
|
||||
val betaPromptShown = TypedStoreKey<Boolean>(BETA_PROMPT_SHOWN)
|
||||
val betaTimeline = TypedStoreKey<Boolean>(BETA_TIMELINE)
|
||||
val enableBackup = TypedStoreKey<Boolean>(ENABLE_BACKUP)
|
||||
val useWifiForUploadVideos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_VIDEOS)
|
||||
val useWifiForUploadPhotos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_PHOTOS)
|
||||
val needBetaMigration = TypedStoreKey<Boolean>(NEED_BETA_MIGRATION)
|
||||
val shouldResetSync = TypedStoreKey<Boolean>(SHOULD_RESET_SYNC)
|
||||
}
|
||||
}
|
||||
|
||||
enum class TaskStatus {
|
||||
DOWNLOAD_PENDING,
|
||||
DOWNLOAD_QUEUED,
|
||||
DOWNLOAD_FAILED,
|
||||
UPLOAD_PENDING,
|
||||
UPLOAD_QUEUED,
|
||||
UPLOAD_FAILED,
|
||||
UPLOAD_COMPLETE
|
||||
}
|
||||
|
||||
enum class BackupSelection {
|
||||
SELECTED,
|
||||
NONE,
|
||||
EXCLUDED
|
||||
}
|
||||
|
||||
enum class AvatarColor {
|
||||
PRIMARY,
|
||||
PINK,
|
||||
RED,
|
||||
YELLOW,
|
||||
BLUE,
|
||||
GREEN,
|
||||
PURPLE,
|
||||
ORANGE,
|
||||
GRAY,
|
||||
AMBER
|
||||
}
|
||||
|
||||
enum class AlbumUserRole {
|
||||
EDITOR,
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum class MemoryType {
|
||||
ON_THIS_DAY
|
||||
}
|
||||
|
||||
enum class AssetVisibility {
|
||||
TIMELINE,
|
||||
HIDDEN,
|
||||
ARCHIVE,
|
||||
LOCKED
|
||||
}
|
||||
|
||||
enum class SourceType(val value: String) {
|
||||
MACHINE_LEARNING("machine-learning"),
|
||||
EXIF("exif"),
|
||||
MANUAL("manual");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): SourceType? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class UploadMethod {
|
||||
MULTIPART,
|
||||
RESUMABLE
|
||||
}
|
||||
|
||||
enum class UploadErrorCode {
|
||||
UNKNOWN,
|
||||
ASSET_NOT_FOUND,
|
||||
FILE_NOT_FOUND,
|
||||
RESOURCE_NOT_FOUND,
|
||||
INVALID_RESOURCE,
|
||||
ENCODING_FAILED,
|
||||
WRITE_FAILED,
|
||||
NOT_ENOUGH_SPACE,
|
||||
NETWORK_ERROR,
|
||||
PHOTOS_INTERNAL_ERROR,
|
||||
PHOTOS_UNKNOWN_ERROR,
|
||||
NO_SERVER_URL,
|
||||
NO_DEVICE_ID,
|
||||
NO_ACCESS_TOKEN,
|
||||
INTERRUPTED,
|
||||
CANCELLED,
|
||||
DOWNLOAD_STALLED,
|
||||
FORCE_QUIT,
|
||||
OUT_OF_RESOURCES,
|
||||
BACKGROUND_UPDATES_DISABLED,
|
||||
UPLOAD_TIMEOUT,
|
||||
ICLOUD_RATE_LIMIT,
|
||||
ICLOUD_THROTTLED,
|
||||
INVALID_SERVER_RESPONSE,
|
||||
}
|
||||
|
||||
enum class AssetType {
|
||||
OTHER,
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO
|
||||
}
|
||||
|
||||
enum class EndpointStatus(val value: String) {
|
||||
LOADING("loading"),
|
||||
VALID("valid"),
|
||||
ERROR("error"),
|
||||
UNKNOWN("unknown");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): EndpointStatus? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint data class
|
||||
data class Endpoint(
|
||||
val url: String,
|
||||
val status: EndpointStatus
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import app.alextran.immich.upload.TaskConfig
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface LocalAssetDao {
|
||||
@Query("""
|
||||
SELECT a.id, a.type FROM local_asset_entity a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa
|
||||
INNER JOIN local_album_entity la ON laa.album_id = la.id
|
||||
WHERE laa.asset_id = a.id
|
||||
AND la.backup_selection = 0 -- selected
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa2
|
||||
INNER JOIN local_album_entity la2 ON laa2.album_id = la2.id
|
||||
WHERE laa2.asset_id = a.id
|
||||
AND la2.backup_selection = 2 -- excluded
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity ra
|
||||
WHERE ra.checksum = a.checksum
|
||||
AND ra.owner_id = (SELECT string_value FROM store_entity WHERE id = 14) -- current_user
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM upload_tasks ut
|
||||
WHERE ut.local_id = a.id
|
||||
)
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getCandidatesForBackup(limit: Int): List<BackupCandidate>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface StoreDao {
|
||||
@Query("SELECT * FROM store_entity WHERE id = :key")
|
||||
suspend fun get(key: StoreKey): Store?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(store: Store)
|
||||
|
||||
// Extension functions for type-safe access
|
||||
suspend fun <T> get(
|
||||
typedKey: TypedStoreKey<T>,
|
||||
storage: StorageType<T>
|
||||
): T? {
|
||||
val store = get(typedKey.key) ?: return null
|
||||
|
||||
return when (storage) {
|
||||
is StorageType.IntStorage,
|
||||
is StorageType.BoolStorage,
|
||||
is StorageType.DateStorage -> {
|
||||
store.intValue?.let { storage.fromDb(it) }
|
||||
}
|
||||
else -> {
|
||||
store.stringValue?.let { storage.fromDb(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> set(
|
||||
typedKey: TypedStoreKey<T>,
|
||||
value: T,
|
||||
storage: StorageType<T>
|
||||
) {
|
||||
val dbValue = storage.toDb(value)
|
||||
|
||||
val store = when (storage) {
|
||||
is StorageType.IntStorage,
|
||||
is StorageType.BoolStorage,
|
||||
is StorageType.DateStorage -> {
|
||||
Store(
|
||||
id = typedKey.key,
|
||||
stringValue = null,
|
||||
intValue = dbValue as Int
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Store(
|
||||
id = typedKey.key,
|
||||
stringValue = dbValue as String,
|
||||
intValue = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
insert(store)
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface UploadTaskDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(tasks: List<UploadTask>)
|
||||
|
||||
@Query("""
|
||||
SELECT id FROM upload_tasks
|
||||
WHERE status IN (:statuses)
|
||||
""")
|
||||
suspend fun getTaskIdsByStatus(statuses: List<TaskStatus>): List<Long>
|
||||
|
||||
@Query("""
|
||||
UPDATE upload_tasks
|
||||
SET status = 3, -- upload_pending
|
||||
file_path = NULL,
|
||||
attempts = 0
|
||||
WHERE id IN (:taskIds)
|
||||
""")
|
||||
suspend fun resetOrphanedTasks(taskIds: List<Long>)
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
t.attempts,
|
||||
a.checksum,
|
||||
a.created_at as createdAt,
|
||||
a.name as fileName,
|
||||
t.file_path as filePath,
|
||||
a.is_favorite as isFavorite,
|
||||
a.id as localId,
|
||||
t.priority,
|
||||
t.id as taskId,
|
||||
a.type,
|
||||
a.updated_at as updatedAt
|
||||
FROM upload_tasks t
|
||||
INNER JOIN local_asset_entity a ON t.local_id = a.id
|
||||
WHERE t.status = 3 -- upload_pending
|
||||
AND t.attempts < :maxAttempts
|
||||
AND a.checksum IS NOT NULL
|
||||
AND (t.retry_after IS NULL OR t.retry_after <= :currentTime)
|
||||
ORDER BY t.priority DESC, t.created_at ASC
|
||||
LIMIT :limit
|
||||
""")
|
||||
suspend fun getTasksForUpload(limit: Int, maxAttempts: Int = TaskConfig.MAX_ATTEMPTS, currentTime: Long = System.currentTimeMillis() / 1000): List<LocalAssetTaskData>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM upload_tasks WHERE status = 3 LIMIT 1)") // upload_pending
|
||||
suspend fun hasPendingTasks(): Boolean
|
||||
|
||||
@Query("""
|
||||
UPDATE upload_tasks
|
||||
SET attempts = :attempts,
|
||||
last_error = :errorCode,
|
||||
status = :status,
|
||||
retry_after = :retryAfter
|
||||
WHERE id = :taskId
|
||||
""")
|
||||
suspend fun updateTaskAfterFailure(
|
||||
taskId: Long,
|
||||
attempts: Int,
|
||||
errorCode: UploadErrorCode,
|
||||
status: TaskStatus,
|
||||
retryAfter: Date?
|
||||
)
|
||||
|
||||
@Query("UPDATE upload_tasks SET status = :status WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: TaskStatus)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface UploadTaskStatDao {
|
||||
@Query("SELECT * FROM upload_task_stats")
|
||||
suspend fun getStats(): UploadTaskStat?
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import com.google.gson.Gson
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
// Sealed interface representing storage types
|
||||
sealed interface StorageType<T> {
|
||||
fun toDb(value: T): Any
|
||||
fun fromDb(value: Any): T
|
||||
|
||||
data object IntStorage : StorageType<Int> {
|
||||
override fun toDb(value: Int) = value
|
||||
override fun fromDb(value: Any) = value as Int
|
||||
}
|
||||
|
||||
data object BoolStorage : StorageType<Boolean> {
|
||||
override fun toDb(value: Boolean) = if (value) 1 else 0
|
||||
override fun fromDb(value: Any) = (value as Int) == 1
|
||||
}
|
||||
|
||||
data object StringStorage : StorageType<String> {
|
||||
override fun toDb(value: String) = value
|
||||
override fun fromDb(value: Any) = value as String
|
||||
}
|
||||
|
||||
data object DateStorage : StorageType<Date> {
|
||||
override fun toDb(value: Date) = value.time / 1000
|
||||
override fun fromDb(value: Any) = Date((value as Long) * 1000)
|
||||
}
|
||||
|
||||
data object UrlStorage : StorageType<URL> {
|
||||
override fun toDb(value: URL) = value.toString()
|
||||
override fun fromDb(value: Any) = URL(value as String)
|
||||
}
|
||||
|
||||
class JsonStorage<T>(
|
||||
private val clazz: Class<T>,
|
||||
private val gson: Gson = Gson()
|
||||
) : StorageType<T> {
|
||||
override fun toDb(value: T) = gson.toJson(value)
|
||||
override fun fromDb(value: Any) = gson.fromJson(value as String, clazz)
|
||||
}
|
||||
}
|
||||
|
||||
// Typed key wrapper
|
||||
@JvmInline
|
||||
value class TypedStoreKey<T>(val key: StoreKey) {
|
||||
companion object {
|
||||
// Factory methods for type-safe key creation
|
||||
inline fun <reified T> of(key: StoreKey): TypedStoreKey<T> = TypedStoreKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Registry mapping keys to their storage types
|
||||
object StoreRegistry {
|
||||
private val intKeys = setOf(
|
||||
StoreKey.VERSION,
|
||||
StoreKey.DEVICE_ID_HASH,
|
||||
StoreKey.BACKUP_TRIGGER_DELAY
|
||||
)
|
||||
|
||||
private val stringKeys = setOf(
|
||||
StoreKey.CURRENT_USER,
|
||||
StoreKey.DEVICE_ID,
|
||||
StoreKey.ACCESS_TOKEN
|
||||
)
|
||||
|
||||
fun usesIntStorage(key: StoreKey): Boolean = key in intKeys
|
||||
fun usesStringStorage(key: StoreKey): Boolean = key in stringKeys
|
||||
}
|
||||
|
||||
// Storage type registry for automatic selection
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
object StorageTypes {
|
||||
inline fun <reified T> get(): StorageType<T> = when (T::class) {
|
||||
Int::class -> StorageType.IntStorage as StorageType<T>
|
||||
Boolean::class -> StorageType.BoolStorage as StorageType<T>
|
||||
String::class -> StorageType.StringStorage as StorageType<T>
|
||||
Date::class -> StorageType.DateStorage as StorageType<T>
|
||||
URL::class -> StorageType.UrlStorage as StorageType<T>
|
||||
else -> StorageType.JsonStorage(T::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified extension functions with automatic storage
|
||||
suspend inline fun <reified T> StoreDao.get(typedKey: TypedStoreKey<T>): T? {
|
||||
return get(typedKey, StorageTypes.get<T>())
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> StoreDao.set(typedKey: TypedStoreKey<T>, value: T) {
|
||||
set(typedKey, value, StorageTypes.get<T>())
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package app.alextran.immich.schema
|
||||
|
||||
import androidx.room.*
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "asset_face_entity")
|
||||
data class AssetFace(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "person_id")
|
||||
val personId: String?,
|
||||
@ColumnInfo(name = "image_width")
|
||||
val imageWidth: Int,
|
||||
@ColumnInfo(name = "image_height")
|
||||
val imageHeight: Int,
|
||||
@ColumnInfo(name = "bounding_box_x1")
|
||||
val boundingBoxX1: Int,
|
||||
@ColumnInfo(name = "bounding_box_y1")
|
||||
val boundingBoxY1: Int,
|
||||
@ColumnInfo(name = "bounding_box_x2")
|
||||
val boundingBoxX2: Int,
|
||||
@ColumnInfo(name = "bounding_box_y2")
|
||||
val boundingBoxY2: Int,
|
||||
@ColumnInfo(name = "source_type")
|
||||
val sourceType: SourceType
|
||||
)
|
||||
|
||||
@Entity(tableName = "auth_user_entity")
|
||||
data class AuthUser(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "is_admin")
|
||||
val isAdmin: Boolean,
|
||||
@ColumnInfo(name = "has_profile_image")
|
||||
val hasProfileImage: Boolean,
|
||||
@ColumnInfo(name = "profile_changed_at")
|
||||
val profileChangedAt: Date,
|
||||
@ColumnInfo(name = "avatar_color")
|
||||
val avatarColor: AvatarColor,
|
||||
@ColumnInfo(name = "quota_size_in_bytes")
|
||||
val quotaSizeInBytes: Int,
|
||||
@ColumnInfo(name = "quota_usage_in_bytes")
|
||||
val quotaUsageInBytes: Int,
|
||||
@ColumnInfo(name = "pin_code")
|
||||
val pinCode: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "local_album_entity")
|
||||
data class LocalAlbum(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "backup_selection")
|
||||
val backupSelection: BackupSelection,
|
||||
@ColumnInfo(name = "linked_remote_album_id")
|
||||
val linkedRemoteAlbumId: String?,
|
||||
@ColumnInfo(name = "marker")
|
||||
val marker: Boolean?,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "is_ios_shared_album")
|
||||
val isIosSharedAlbum: Boolean,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_album_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class LocalAlbumAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String,
|
||||
@ColumnInfo(name = "marker")
|
||||
val marker: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "local_asset_entity")
|
||||
data class LocalAsset(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val checksum: String?,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "duration_in_seconds")
|
||||
val durationInSeconds: Int?,
|
||||
val height: Int?,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
val name: String,
|
||||
val orientation: String,
|
||||
val type: AssetType,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
val width: Int?
|
||||
)
|
||||
|
||||
data class BackupCandidate(
|
||||
val id: String,
|
||||
val type: AssetType
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "memory_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class MemoryAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "memory_entity")
|
||||
data class Memory(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "deleted_at")
|
||||
val deletedAt: Date?,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
val type: MemoryType,
|
||||
val data: String,
|
||||
@ColumnInfo(name = "is_saved")
|
||||
val isSaved: Boolean,
|
||||
@ColumnInfo(name = "memory_at")
|
||||
val memoryAt: Date,
|
||||
@ColumnInfo(name = "seen_at")
|
||||
val seenAt: Date?,
|
||||
@ColumnInfo(name = "show_at")
|
||||
val showAt: Date?,
|
||||
@ColumnInfo(name = "hide_at")
|
||||
val hideAt: Date?
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "partner_entity",
|
||||
primaryKeys = ["shared_by_id", "shared_with_id"]
|
||||
)
|
||||
data class Partner(
|
||||
@ColumnInfo(name = "shared_by_id")
|
||||
val sharedById: String,
|
||||
@ColumnInfo(name = "shared_with_id")
|
||||
val sharedWithId: String,
|
||||
@ColumnInfo(name = "in_timeline")
|
||||
val inTimeline: Boolean
|
||||
)
|
||||
|
||||
@Entity(tableName = "person_entity")
|
||||
data class Person(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "face_asset_id")
|
||||
val faceAssetId: String?,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
@ColumnInfo(name = "is_hidden")
|
||||
val isHidden: Boolean,
|
||||
val color: String?,
|
||||
@ColumnInfo(name = "birth_date")
|
||||
val birthDate: Date?
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_album_entity")
|
||||
data class RemoteAlbum(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
val description: String?,
|
||||
@ColumnInfo(name = "is_activity_enabled")
|
||||
val isActivityEnabled: Boolean,
|
||||
val name: String,
|
||||
val order: Int,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "thumbnail_asset_id")
|
||||
val thumbnailAssetId: String?,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_album_asset_entity",
|
||||
primaryKeys = ["asset_id", "album_id"]
|
||||
)
|
||||
data class RemoteAlbumAsset(
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_album_user_entity",
|
||||
primaryKeys = ["album_id", "user_id"]
|
||||
)
|
||||
data class RemoteAlbumUser(
|
||||
@ColumnInfo(name = "album_id")
|
||||
val albumId: String,
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
val role: AlbumUserRole
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_asset_entity")
|
||||
data class RemoteAsset(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val checksum: String,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean,
|
||||
@ColumnInfo(name = "deleted_at")
|
||||
val deletedAt: Date?,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "local_date_time")
|
||||
val localDateTime: Date?,
|
||||
@ColumnInfo(name = "thumb_hash")
|
||||
val thumbHash: String?,
|
||||
@ColumnInfo(name = "library_id")
|
||||
val libraryId: String?,
|
||||
@ColumnInfo(name = "live_photo_video_id")
|
||||
val livePhotoVideoId: String?,
|
||||
@ColumnInfo(name = "stack_id")
|
||||
val stackId: String?,
|
||||
val visibility: AssetVisibility
|
||||
)
|
||||
|
||||
@Entity(tableName = "remote_exif_entity")
|
||||
data class RemoteExif(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "asset_id")
|
||||
val assetId: String,
|
||||
val city: String?,
|
||||
val state: String?,
|
||||
val country: String?,
|
||||
@ColumnInfo(name = "date_time_original")
|
||||
val dateTimeOriginal: Date?,
|
||||
val description: String?,
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
@ColumnInfo(name = "exposure_time")
|
||||
val exposureTime: String?,
|
||||
@ColumnInfo(name = "f_number")
|
||||
val fNumber: Double?,
|
||||
@ColumnInfo(name = "file_size")
|
||||
val fileSize: Int?,
|
||||
@ColumnInfo(name = "focal_length")
|
||||
val focalLength: Double?,
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
val iso: Int?,
|
||||
val make: String?,
|
||||
val model: String?,
|
||||
val lens: String?,
|
||||
val orientation: String?,
|
||||
@ColumnInfo(name = "time_zone")
|
||||
val timeZone: String?,
|
||||
val rating: Int?,
|
||||
@ColumnInfo(name = "projection_type")
|
||||
val projectionType: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "stack_entity")
|
||||
data class Stack(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Date,
|
||||
@ColumnInfo(name = "owner_id")
|
||||
val ownerId: String,
|
||||
@ColumnInfo(name = "primary_asset_id")
|
||||
val primaryAssetId: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "store_entity")
|
||||
data class Store(
|
||||
@PrimaryKey
|
||||
val id: StoreKey,
|
||||
@ColumnInfo(name = "string_value")
|
||||
val stringValue: String?,
|
||||
@ColumnInfo(name = "int_value")
|
||||
val intValue: Int?
|
||||
)
|
||||
|
||||
@Entity(tableName = "upload_task_entity")
|
||||
data class UploadTask(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val attempts: Int,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Date,
|
||||
@ColumnInfo(name = "file_path")
|
||||
val filePath: URL?,
|
||||
@ColumnInfo(name = "is_live_photo")
|
||||
val isLivePhoto: Boolean?,
|
||||
@ColumnInfo(name = "last_error")
|
||||
val lastError: UploadErrorCode?,
|
||||
@ColumnInfo(name = "live_photo_video_id")
|
||||
val livePhotoVideoId: String?,
|
||||
@ColumnInfo(name = "local_id")
|
||||
val localId: String,
|
||||
val method: UploadMethod,
|
||||
val priority: Float,
|
||||
@ColumnInfo(name = "retry_after")
|
||||
val retryAfter: Date?,
|
||||
val status: TaskStatus
|
||||
)
|
||||
|
||||
// Data class for query results
|
||||
data class LocalAssetTaskData(
|
||||
val attempts: Int,
|
||||
val checksum: String,
|
||||
val createdAt: Date,
|
||||
val fileName: String,
|
||||
val filePath: URL?,
|
||||
val isFavorite: Boolean,
|
||||
val localId: String,
|
||||
val priority: Float,
|
||||
val taskId: Long,
|
||||
val type: AssetType,
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@Entity(tableName = "upload_task_stats")
|
||||
data class UploadTaskStat(
|
||||
@ColumnInfo(name = "pending_downloads")
|
||||
val pendingDownloads: Int,
|
||||
@ColumnInfo(name = "pending_uploads")
|
||||
val pendingUploads: Int,
|
||||
@ColumnInfo(name = "queued_downloads")
|
||||
val queuedDownloads: Int,
|
||||
@ColumnInfo(name = "queued_uploads")
|
||||
val queuedUploads: Int,
|
||||
@ColumnInfo(name = "failed_downloads")
|
||||
val failedDownloads: Int,
|
||||
@ColumnInfo(name = "failed_uploads")
|
||||
val failedUploads: Int,
|
||||
@ColumnInfo(name = "completed_uploads")
|
||||
val completedUploads: Int
|
||||
)
|
||||
|
||||
@Entity(tableName = "user_entity")
|
||||
data class User(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "has_profile_image")
|
||||
val hasProfileImage: Boolean,
|
||||
@ColumnInfo(name = "profile_changed_at")
|
||||
val profileChangedAt: Date,
|
||||
@ColumnInfo(name = "avatar_color")
|
||||
val avatarColor: AvatarColor
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "user_metadata_entity",
|
||||
primaryKeys = ["user_id", "key"]
|
||||
)
|
||||
data class UserMetadata(
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
val key: Date,
|
||||
val value: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as UserMetadata
|
||||
|
||||
if (userId != other.userId) return false
|
||||
if (key != other.key) return false
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = userId.hashCode()
|
||||
result = 31 * result + key.hashCode()
|
||||
result = 31 * result + value.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
object NetworkMonitor {
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
@Volatile
|
||||
private var isWifi = false
|
||||
|
||||
fun initialize(context: Context) {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
isConnected = true
|
||||
checkWifi(connectivityManager, network)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
isConnected = false
|
||||
isWifi = false
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
checkWifi(connectivityManager, network)
|
||||
}
|
||||
|
||||
private fun checkWifi(cm: ConnectivityManager, network: Network) {
|
||||
val capabilities = cm.getNetworkCapabilities(network)
|
||||
isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean = isConnected
|
||||
|
||||
fun isWifiConnected(context: Context): Boolean {
|
||||
if (!isConnected) return false
|
||||
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
object TaskConfig {
|
||||
const val MAX_ATTEMPTS = 3
|
||||
const val MAX_PENDING_DOWNLOADS = 10
|
||||
const val MAX_PENDING_UPLOADS = 10
|
||||
const val MAX_ACTIVE_UPLOADS = 3
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.upload
|
||||
|
||||
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 UploadTaskPigeonUtils {
|
||||
|
||||
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()
|
||||
|
||||
enum class UploadApiErrorCode(val raw: Int) {
|
||||
UNKNOWN(0),
|
||||
ASSET_NOT_FOUND(1),
|
||||
FILE_NOT_FOUND(2),
|
||||
RESOURCE_NOT_FOUND(3),
|
||||
INVALID_RESOURCE(4),
|
||||
ENCODING_FAILED(5),
|
||||
WRITE_FAILED(6),
|
||||
NOT_ENOUGH_SPACE(7),
|
||||
NETWORK_ERROR(8),
|
||||
PHOTOS_INTERNAL_ERROR(9),
|
||||
PHOTOS_UNKNOWN_ERROR(10),
|
||||
NO_SERVER_URL(11),
|
||||
NO_DEVICE_ID(12),
|
||||
NO_ACCESS_TOKEN(13),
|
||||
INTERRUPTED(14),
|
||||
CANCELLED(15),
|
||||
DOWNLOAD_STALLED(16),
|
||||
FORCE_QUIT(17),
|
||||
OUT_OF_RESOURCES(18),
|
||||
BACKGROUND_UPDATES_DISABLED(19),
|
||||
UPLOAD_TIMEOUT(20),
|
||||
I_CLOUD_RATE_LIMIT(21),
|
||||
I_CLOUD_THROTTLED(22);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): UploadApiErrorCode? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UploadApiStatus(val raw: Int) {
|
||||
DOWNLOAD_PENDING(0),
|
||||
DOWNLOAD_QUEUED(1),
|
||||
DOWNLOAD_FAILED(2),
|
||||
UPLOAD_PENDING(3),
|
||||
UPLOAD_QUEUED(4),
|
||||
UPLOAD_FAILED(5),
|
||||
UPLOAD_COMPLETE(6),
|
||||
UPLOAD_SKIPPED(7);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): UploadApiStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class UploadApiTaskStatus (
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val status: UploadApiStatus,
|
||||
val errorCode: UploadApiErrorCode? = null,
|
||||
val httpStatusCode: Long? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskStatus {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val filename = pigeonVar_list[1] as String
|
||||
val status = pigeonVar_list[2] as UploadApiStatus
|
||||
val errorCode = pigeonVar_list[3] as UploadApiErrorCode?
|
||||
val httpStatusCode = pigeonVar_list[4] as Long?
|
||||
return UploadApiTaskStatus(id, filename, status, errorCode, httpStatusCode)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
filename,
|
||||
status,
|
||||
errorCode,
|
||||
httpStatusCode,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is UploadApiTaskStatus) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class UploadApiTaskProgress (
|
||||
val id: String,
|
||||
val progress: Double,
|
||||
val speed: Double? = null,
|
||||
val totalBytes: Long? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskProgress {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val progress = pigeonVar_list[1] as Double
|
||||
val speed = pigeonVar_list[2] as Double?
|
||||
val totalBytes = pigeonVar_list[3] as Long?
|
||||
return UploadApiTaskProgress(id, progress, speed, totalBytes)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
progress,
|
||||
speed,
|
||||
totalBytes,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is UploadApiTaskProgress) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class UploadTaskPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
UploadApiErrorCode.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
UploadApiStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
UploadApiTaskStatus.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
UploadApiTaskProgress.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is UploadApiErrorCode -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is UploadApiStatus -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is UploadApiTaskStatus -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is UploadApiTaskProgress -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val UploadTaskPigeonMethodCodec = StandardMethodCodec(UploadTaskPigeonCodec())
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface UploadApi {
|
||||
fun initialize(callback: (Result<Unit>) -> Unit)
|
||||
fun refresh(callback: (Result<Unit>) -> Unit)
|
||||
fun cancelAll(callback: (Result<Unit>) -> Unit)
|
||||
fun enqueueAssets(localIds: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun enqueueFiles(paths: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by UploadApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
UploadTaskPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.initialize$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.initialize{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.refresh$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.refresh{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.cancelAll{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val localIdsArg = args[0] as List<String>
|
||||
api.enqueueAssets(localIdsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val pathsArg = args[0] as List<String>
|
||||
api.enqueueFiles(pathsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonStreamHandler<T>(
|
||||
val wrapper: UploadTaskPigeonEventChannelWrapper<T>
|
||||
) : EventChannel.StreamHandler {
|
||||
var pigeonSink: PigeonEventSink<T>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: EventChannel.EventSink) {
|
||||
pigeonSink = PigeonEventSink<T>(sink)
|
||||
wrapper.onListen(p0, pigeonSink!!)
|
||||
}
|
||||
|
||||
override fun onCancel(p0: Any?) {
|
||||
pigeonSink = null
|
||||
wrapper.onCancel(p0)
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadTaskPigeonEventChannelWrapper<T> {
|
||||
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
|
||||
|
||||
open fun onCancel(p0: Any?) {}
|
||||
}
|
||||
|
||||
class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
|
||||
fun success(value: T) {
|
||||
sink.success(value)
|
||||
}
|
||||
|
||||
fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
sink.error(errorCode, errorMessage, errorDetails)
|
||||
}
|
||||
|
||||
fun endOfStream() {
|
||||
sink.endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StreamStatusStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskStatus> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: StreamStatusStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskStatus>(streamHandler)
|
||||
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StreamProgressStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskProgress> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: StreamProgressStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskProgress>(streamHandler)
|
||||
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import app.alextran.immich.schema.AppDatabase
|
||||
import app.alextran.immich.schema.AssetType
|
||||
import app.alextran.immich.schema.StorageType
|
||||
import app.alextran.immich.schema.StoreKey
|
||||
import app.alextran.immich.schema.TaskStatus
|
||||
import app.alextran.immich.schema.UploadMethod
|
||||
import app.alextran.immich.schema.UploadTask
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.guava.await
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// TODO: this is almost entirely LLM-generated (ported from Swift), need to verify behavior
|
||||
class UploadTaskImpl(context: Context) : UploadApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val db: AppDatabase = AppDatabase.getDatabase(ctx)
|
||||
private val workManager: WorkManager = WorkManager.getInstance(ctx)
|
||||
|
||||
@Volatile
|
||||
private var isInitialized = false
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun initialize(callback: (Result<Unit>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
// Clean up orphaned tasks
|
||||
val activeWorkInfos = workManager.getWorkInfosByTag(UPLOAD_WORK_TAG).await()
|
||||
val activeTaskIds = activeWorkInfos
|
||||
.filter { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED }
|
||||
.mapNotNull {
|
||||
it.tags.find { tag -> tag.startsWith("task_") }?.substringAfter("task_")?.toLongOrNull()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
db.uploadTaskDao().run {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Find tasks marked as queued but not actually running
|
||||
val dbQueuedIds = getTaskIdsByStatus(
|
||||
listOf(
|
||||
TaskStatus.DOWNLOAD_QUEUED,
|
||||
TaskStatus.UPLOAD_QUEUED,
|
||||
TaskStatus.UPLOAD_PENDING
|
||||
)
|
||||
)
|
||||
|
||||
val orphanIds = dbQueuedIds.filterNot { it in activeTaskIds }
|
||||
|
||||
if (orphanIds.isNotEmpty()) {
|
||||
resetOrphanedTasks(orphanIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp files
|
||||
val tempDir = getTempDirectory()
|
||||
tempDir.deleteRecursively()
|
||||
|
||||
isInitialized = true
|
||||
startBackup()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun refresh(callback: (Result<Unit>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
startBackup()
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startBackup() {
|
||||
if (!isInitialized) return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if backup is enabled
|
||||
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
|
||||
if (backupEnabled != true) return@withContext
|
||||
|
||||
// Get upload statistics
|
||||
val stats = db.uploadTaskStatDao().getStats() ?: return@withContext
|
||||
val availableSlots = TaskConfig.MAX_PENDING_UPLOADS + TaskConfig.MAX_PENDING_DOWNLOADS -
|
||||
(stats.pendingDownloads + stats.queuedDownloads + stats.pendingUploads + stats.queuedUploads)
|
||||
|
||||
if (availableSlots <= 0) return@withContext
|
||||
|
||||
// Find candidate assets for backup
|
||||
val candidates = db.localAssetDao().getCandidatesForBackup(availableSlots)
|
||||
|
||||
if (candidates.isEmpty()) return@withContext
|
||||
|
||||
// Create upload tasks for candidates
|
||||
db.uploadTaskDao().insertAll(candidates.map { candidate ->
|
||||
UploadTask(
|
||||
attempts = 0,
|
||||
createdAt = Date(),
|
||||
filePath = null,
|
||||
isLivePhoto = null,
|
||||
lastError = null,
|
||||
livePhotoVideoId = null,
|
||||
localId = candidate.id,
|
||||
method = UploadMethod.MULTIPART,
|
||||
priority = when (candidate.type) {
|
||||
AssetType.IMAGE -> 0.5f
|
||||
else -> 0.3f
|
||||
},
|
||||
retryAfter = null,
|
||||
status = TaskStatus.UPLOAD_PENDING
|
||||
)
|
||||
})
|
||||
|
||||
// Start upload workers
|
||||
enqueueUploadWorkers()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Backup queue error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueUploadWorkers() {
|
||||
// Create constraints
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
// Create work request
|
||||
val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(UPLOAD_WORK_TAG)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
UPLOAD_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
uploadWorkRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTempDirectory(): java.io.File {
|
||||
return java.io.File(ctx.cacheDir, "upload_temp").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadTaskImpl"
|
||||
private const val UPLOAD_WORK_TAG = "immich_upload"
|
||||
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package app.alextran.immich.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import androidx.work.*
|
||||
import app.alextran.immich.schema.AppDatabase
|
||||
import app.alextran.immich.schema.AssetType
|
||||
import app.alextran.immich.schema.LocalAssetTaskData
|
||||
import app.alextran.immich.schema.StorageType
|
||||
import app.alextran.immich.schema.StoreKey
|
||||
import app.alextran.immich.schema.TaskStatus
|
||||
import app.alextran.immich.schema.UploadErrorCode
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private val db = AppDatabase.getDatabase(applicationContext)
|
||||
private val client = createOkHttpClient()
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if backup is enabled
|
||||
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
|
||||
if (backupEnabled != true) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
// Get pending upload tasks
|
||||
val tasks = db.uploadTaskDao().getTasksForUpload(TaskConfig.MAX_ACTIVE_UPLOADS)
|
||||
|
||||
if (tasks.isEmpty()) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
// Process tasks concurrently
|
||||
val results = tasks.map { task ->
|
||||
async { processUploadTask(task) }
|
||||
}.awaitAll()
|
||||
|
||||
// Check if we should continue processing
|
||||
val hasMore = db.uploadTaskDao().hasPendingTasks()
|
||||
|
||||
if (hasMore) {
|
||||
// Schedule next batch
|
||||
enqueueNextBatch()
|
||||
}
|
||||
|
||||
// Determine result based on processing outcomes
|
||||
when {
|
||||
results.all { it } -> Result.success()
|
||||
results.any { it } -> Result.success() // Partial success
|
||||
else -> Result.retry()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Upload worker error", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processUploadTask(task: LocalAssetTaskData): Boolean {
|
||||
return try {
|
||||
// Get asset from MediaStore
|
||||
val assetUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
.buildUpon()
|
||||
.appendPath(task.localId)
|
||||
.build()
|
||||
|
||||
val cursor = applicationContext.contentResolver.query(
|
||||
assetUri,
|
||||
arrayOf(MediaStore.Images.Media.DATA),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
) ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
|
||||
|
||||
val filePath = cursor.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
|
||||
} else null
|
||||
} ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
|
||||
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
return handleFailure(task, UploadErrorCode.FILE_NOT_FOUND)
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
val serverUrl = db.storeDao().get(StoreKey.serverEndpoint, StorageType.UrlStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_SERVER_URL)
|
||||
val accessToken = db.storeDao().get(StoreKey.accessToken, StorageType.StringStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_ACCESS_TOKEN)
|
||||
val deviceId = db.storeDao().get(StoreKey.deviceId, StorageType.StringStorage)
|
||||
?: return handleFailure(task, UploadErrorCode.NO_DEVICE_ID)
|
||||
|
||||
// Check network constraints
|
||||
val useWifiOnly = when (task.type) {
|
||||
AssetType.IMAGE -> db.storeDao().get(StoreKey.useWifiForUploadPhotos, StorageType.BoolStorage) ?: false
|
||||
AssetType.VIDEO -> db.storeDao().get(StoreKey.useWifiForUploadVideos, StorageType.BoolStorage) ?: false
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (useWifiOnly && !NetworkMonitor.isWifiConnected(applicationContext)) {
|
||||
// Wait for WiFi
|
||||
return true
|
||||
}
|
||||
|
||||
// Update task status
|
||||
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_QUEUED)
|
||||
|
||||
// Perform upload
|
||||
uploadFile(task, file, serverUrl, accessToken, deviceId)
|
||||
|
||||
// Mark as complete
|
||||
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_COMPLETE)
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Upload task ${task.taskId} failed", e)
|
||||
handleFailure(task, UploadErrorCode.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadFile(
|
||||
task: LocalAssetTaskData,
|
||||
file: File,
|
||||
serverUrl: URL,
|
||||
accessToken: String,
|
||||
deviceId: String
|
||||
) {
|
||||
val requestBody = createMultipartBody(task, file, deviceId)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl}/api/upload")
|
||||
.post(requestBody)
|
||||
.header("x-immich-user-token", accessToken)
|
||||
.tag(task.taskId)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Upload failed: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMultipartBody(
|
||||
task: LocalAssetTaskData,
|
||||
file: File,
|
||||
deviceId: String
|
||||
): RequestBody {
|
||||
val boundary = "Boundary-${UUID.randomUUID()}"
|
||||
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||
|
||||
override fun writeTo(sink: okio.BufferedSink) {
|
||||
// Write form fields
|
||||
writeFormField(sink, boundary, "deviceAssetId", task.localId)
|
||||
writeFormField(sink, boundary, "deviceId", deviceId)
|
||||
writeFormField(sink, boundary, "fileCreatedAt", (task.createdAt.time / 1000).toString())
|
||||
writeFormField(sink, boundary, "fileModifiedAt", (task.updatedAt.time / 1000).toString())
|
||||
writeFormField(sink, boundary, "fileName", task.fileName)
|
||||
writeFormField(sink, boundary, "isFavorite", task.isFavorite.toString())
|
||||
|
||||
// Write file
|
||||
sink.writeUtf8("--$boundary\r\n")
|
||||
sink.writeUtf8("Content-Disposition: form-data; name=\"assetData\"; filename=\"asset\"\r\n")
|
||||
sink.writeUtf8("Content-Type: application/octet-stream\r\n\r\n")
|
||||
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
sink.write(buffer, 0, bytesRead)
|
||||
|
||||
// Report progress (simplified - could be enhanced with listeners)
|
||||
setProgressAsync(
|
||||
workDataOf(
|
||||
PROGRESS_TASK_ID to task.taskId,
|
||||
PROGRESS_BYTES to file.length()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sink.writeUtf8("\r\n--$boundary--\r\n")
|
||||
}
|
||||
|
||||
private fun writeFormField(sink: okio.BufferedSink, boundary: String, name: String, value: String) {
|
||||
sink.writeUtf8("--$boundary\r\n")
|
||||
sink.writeUtf8("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
|
||||
sink.writeUtf8(value)
|
||||
sink.writeUtf8("\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleFailure(task: LocalAssetTaskData, code: UploadErrorCode): Boolean {
|
||||
val newAttempts = task.attempts + 1
|
||||
val status = if (newAttempts >= TaskConfig.MAX_ATTEMPTS) {
|
||||
TaskStatus.UPLOAD_FAILED
|
||||
} else {
|
||||
TaskStatus.UPLOAD_PENDING
|
||||
}
|
||||
|
||||
val retryAfter = if (status == TaskStatus.UPLOAD_PENDING) {
|
||||
Date(System.currentTimeMillis() + (Math.pow(3.0, newAttempts.toDouble()) * 1000).toLong())
|
||||
} else null
|
||||
|
||||
db.uploadTaskDao().updateTaskAfterFailure(
|
||||
task.taskId,
|
||||
newAttempts,
|
||||
code,
|
||||
status,
|
||||
retryAfter
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun enqueueNextBatch() {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val nextWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(UPLOAD_WORK_TAG)
|
||||
.setInitialDelay(1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(applicationContext)
|
||||
.enqueueUniqueWork(
|
||||
UPLOAD_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
nextWorkRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOkHttpClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(300, TimeUnit.SECONDS)
|
||||
.writeTimeout(300, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadWorker"
|
||||
private const val UPLOAD_WORK_TAG = "immich_upload"
|
||||
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
|
||||
const val PROGRESS_TASK_ID = "progress_task_id"
|
||||
const val PROGRESS_BYTES = "progress_bytes"
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,3 @@ tasks.register("clean", Delete) {
|
||||
tasks.named('wrapper') {
|
||||
distributionType = Wrapper.DistributionType.ALL
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user