diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 870e424461..8b4dc42b7e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,8 @@ plugins { id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version + } def localProperties = new Properties() @@ -45,6 +47,10 @@ android { main.java.srcDirs += 'src/main/kotlin' } + buildFeatures { + compose true + } + defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 @@ -105,6 +111,8 @@ dependencies { def guava_version = '33.3.1-android' def glide_version = '4.16.0' def serialization_version = '1.8.1' + def compose_version = '1.1.1' + def gson_version = '2.10.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" @@ -116,6 +124,17 @@ dependencies { ksp "com.github.bumptech.glide:ksp:$glide_version" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' + + //Glance Widget + implementation "androidx.glance:glance-appwidget:$compose_version" + implementation "com.google.code.gson:gson:$gson_version" + + // Glance Configure + implementation "androidx.activity:activity-compose:1.8.2" + implementation "androidx.compose.ui:ui:$compose_version" + 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" } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index ea6dd795b5..898caee06c 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -25,8 +25,15 @@ @com.google.gson.annotations.SerializedName ; } +# TypeToken preventions +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken + # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken -##---------------End: proguard configuration for Gson ---------- \ No newline at end of file +##---------------End: proguard configuration for Gson ---------- + +# Keep all widget model classes and their fields for Gson +-keep class app.alextran.immich.widget.model.** { *; } \ No newline at end of file diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index cf3b7ee719..3aa72d2876 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -141,6 +141,42 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + + + + + + + + + + + + + + + + + + > + + + + + @@ -154,4 +190,4 @@ - \ No newline at end of file + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt new file mode 100644 index 0000000000..9188df1700 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt @@ -0,0 +1,33 @@ +package app.alextran.immich.widget + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File + +fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, options) + + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) + options.inJustDecodeBounds = false + + return BitmapFactory.decodeFile(file.absolutePath, options) +} + +fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt new file mode 100644 index 0000000000..2a23d0ecbd --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -0,0 +1,241 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.datastore.preferences.core.Preferences +import androidx.glance.* +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.util.UUID +import java.util.concurrent.TimeUnit +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import app.alextran.immich.widget.model.* +import java.time.LocalDate + +class ImageDownloadWorker( + private val context: Context, + workerParameters: WorkerParameters +) : CoroutineWorker(context, workerParameters) { + + companion object { + + private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName + + private fun buildConstraints(): Constraints { + return Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + } + + private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data { + return Data.Builder() + .putString(kWorkerWidgetType, widgetType.toString()) + .putInt(kWorkerWidgetID, appWidgetId) + .build() + } + + fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) { + val manager = WorkManager.getInstance(context) + + val workRequest = PeriodicWorkRequestBuilder( + 20, TimeUnit.MINUTES + ) + .setConstraints(buildConstraints()) + .setInputData(buildInputData(appWidgetId, widgetType)) + .addTag(appWidgetId.toString()) + .build() + + manager.enqueueUniquePeriodicWork( + "$uniqueWorkName-$appWidgetId", + ExistingPeriodicWorkPolicy.UPDATE, + workRequest + ) + } + + fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) { + val manager = WorkManager.getInstance(context) + + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints(buildConstraints()) + .setInputData(buildInputData(appWidgetId, widgetType)) + .addTag(appWidgetId.toString()) + .build() + + manager.enqueueUniqueWork( + "$uniqueWorkName-$appWidgetId", + ExistingWorkPolicy.REPLACE, + workRequest + ) + } + + suspend fun cancel(context: Context, appWidgetId: Int) { + WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId") + + // delete cached image + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentImgUUID = widgetConfig[kImageUUID] + + if (!currentImgUUID.isNullOrEmpty()) { + val file = File(context.cacheDir, imageFilename(currentImgUUID)) + file.delete() + } + } + } + + override suspend fun doWork(): Result { + return try { + val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "") + val widgetId = inputData.getInt(kWorkerWidgetID, -1) + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) + val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentImgUUID = widgetConfig[kImageUUID] + + val serverConfig = ImmichAPI.getServerConfig(context) + + // clear any image caches and go to "login" state if no credentials + if (serverConfig == null) { + if (!currentImgUUID.isNullOrEmpty()) { + deleteImage(currentImgUUID) + updateWidget( + glanceId, + "", + "", + "immich://", + WidgetState.LOG_IN + ) + } + + return Result.success() + } + + // fetch new image + val entry = when (widgetType) { + WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig) + WidgetType.MEMORIES -> fetchMemory(serverConfig) + } + + // clear current image if it exists + if (!currentImgUUID.isNullOrEmpty()) { + deleteImage(currentImgUUID) + } + + // save a new image + val imgUUID = UUID.randomUUID().toString() + saveImage(entry.image, imgUUID) + + // trigger the update routine with new image uuid + updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink) + + Result.success() + } catch (e: Exception) { + Log.e(uniqueWorkName, "Error while loading image", e) + if (runAttemptCount < 10) { + Result.retry() + } else { + Result.failure() + } + } + } + + private suspend fun updateWidget( + glanceId: GlanceId, + imageUUID: String, + subtitle: String?, + deeplink: String?, + widgetState: WidgetState = WidgetState.SUCCESS + ) { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[kNow] = System.currentTimeMillis() + prefs[kImageUUID] = imageUUID + prefs[kWidgetState] = widgetState.toString() + prefs[kSubtitleText] = subtitle ?: "" + prefs[kDeeplinkURL] = deeplink ?: "" + } + + PhotoWidget().update(context,glanceId) + } + + private suspend fun fetchRandom( + serverConfig: ServerConfig, + widgetConfig: Preferences + ): WidgetEntry { + val api = ImmichAPI(serverConfig) + + val filters = SearchFilters(AssetType.IMAGE) + val albumId = widgetConfig[kSelectedAlbum] + val showSubtitle = widgetConfig[kShowAlbumName] + val albumName = widgetConfig[kSelectedAlbumName] + var subtitle: String? = if (showSubtitle == true) albumName else "" + + if (albumId != null) { + filters.albumIds = listOf(albumId) + } + + var randomSearch = api.fetchSearchResults(filters) + + // handle an empty album, fallback to random + if (randomSearch.isEmpty() && albumId != null) { + randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE)) + subtitle = "" + } + + val random = randomSearch.first() + val image = api.fetchImage(random) + + return WidgetEntry( + image, + subtitle, + assetDeeplink(random) + ) + } + + private suspend fun fetchMemory( + serverConfig: ServerConfig + ): WidgetEntry { + val api = ImmichAPI(serverConfig) + + val today = LocalDate.now() + val memories = api.fetchMemory(today) + val asset: Asset + var subtitle: String? = null + + if (memories.isNotEmpty()) { + // pick a random asset from a random memory + val memory = memories.random() + asset = memory.assets.random() + + val yearDiff = today.year - memory.data.year + subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago" + } else { + val filters = SearchFilters(AssetType.IMAGE, size=1) + asset = api.fetchSearchResults(filters).first() + } + + val image = api.fetchImage(asset) + return WidgetEntry( + image, + subtitle, + assetDeeplink(asset) + ) + } + + private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, imageFilename(uuid)) + file.delete() + } + + private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, imageFilename(uuid)) + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt new file mode 100644 index 0000000000..42f5fb4b1b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt @@ -0,0 +1,103 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.alextran.immich.widget.model.* +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import es.antonborri.home_widget.HomeWidgetPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class ImmichAPI(cfg: ServerConfig) { + + companion object { + fun getServerConfig(context: Context): ServerConfig? { + val prefs = HomeWidgetPlugin.getData(context) + + val serverURL = prefs.getString("widget_server_url", "") ?: "" + val sessionKey = prefs.getString("widget_auth_token", "") ?: "" + + if (serverURL.isBlank() || sessionKey.isBlank()) { + return null + } + + return ServerConfig( + serverURL, + sessionKey + ) + } + } + + + private val gson = Gson() + private val serverConfig = cfg + + private fun buildRequestURL(endpoint: String, params: List> = emptyList()): URL { + val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}") + + for ((key, value) in params) { + urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}") + } + + return URL(urlString.toString()) + } + + suspend fun fetchSearchResults(filters: SearchFilters): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/search/random") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + } + + connection.outputStream.use { + OutputStreamWriter(it).use { writer -> + writer.write(gson.toJson(filters)) + writer.flush() + } + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchMemory(date: LocalDate): List = withContext(Dispatchers.IO) { + val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + val url = buildRequestURL("/memories", listOf("for" to iso8601)) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) { + val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview")) + val connection = url.openConnection() + val data = connection.getInputStream().readBytes() + BitmapFactory.decodeByteArray(data, 0, data.size) + ?: throw Exception("Invalid image data") + } + + suspend fun fetchAlbums(): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/albums") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt new file mode 100644 index 0000000000..7721af7d6f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt @@ -0,0 +1,56 @@ +package app.alextran.immich.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import app.alextran.immich.widget.model.* +import es.antonborri.home_widget.HomeWidgetPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MemoryReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = PhotoWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + appWidgetIds.forEach { widgetID -> + ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES) + } + } + + override fun onReceive(context: Context, intent: Intent) { + val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) + + // Launch coroutine to setup a single shot if the app requested the update + if (fromMainApp) { + CoroutineScope(Dispatchers.Default).launch { + val provider = ComponentName(context, MemoryReceiver::class.java) + val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + + glanceIds.forEach { widgetID -> + ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES) + } + } + } + + super.onReceive(context, intent) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + CoroutineScope(Dispatchers.Default).launch { + appWidgetIds.forEach { id -> + ImageDownloadWorker.cancel(context, id) + } + } + } +} + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt new file mode 100644 index 0000000000..b1a0a9de31 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -0,0 +1,124 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.* +import androidx.core.net.toUri +import androidx.datastore.preferences.core.MutablePreferences +import androidx.glance.appwidget.* +import androidx.glance.* +import androidx.glance.action.clickable +import androidx.glance.layout.* +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import app.alextran.immich.R +import app.alextran.immich.widget.model.* +import java.io.File + +class PhotoWidget : GlanceAppWidget() { + override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + val prefs = currentState() + + val imageUUID = prefs[kImageUUID] + val subtitle = prefs[kSubtitleText] + val deeplinkURL = prefs[kDeeplinkURL]?.toUri() + val widgetState = prefs[kWidgetState] + var bitmap: Bitmap? = null + + if (imageUUID != null) { + // fetch a random photo from server + val file = File(context.cacheDir, imageFilename(imageUUID)) + + if (file.exists()) { + bitmap = loadScaledBitmap(file, 500, 500) + } + } + + // WIDGET CONTENT + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.background) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + ) { + if (bitmap != null) { + Image( + provider = ImageProvider(bitmap), + contentDescription = "Widget Image", + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize() + ) + + if (!subtitle.isNullOrBlank()) { + Column( + verticalAlignment = Alignment.Bottom, + horizontalAlignment = Alignment.Start, + modifier = GlanceModifier + .fillMaxSize() + .padding(12.dp) + ) { + Text( + text = subtitle, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 16.sp + ), + modifier = GlanceModifier + .background(ColorProvider(Color(0x99000000))) // 60% black + .padding(8.dp) + .cornerRadius(8.dp) + ) + } + } + } else { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + provider = ImageProvider(R.drawable.splash), + contentDescription = null, + ) + + if (widgetState == WidgetState.LOG_IN.toString()) { + Box( + modifier = GlanceModifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary)) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.fillMaxWidth().padding(16.dp) + ) { + CircularProgressIndicator( + modifier = GlanceModifier.size(12.dp) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary)) + } + } + } + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt new file mode 100644 index 0000000000..39afd76c35 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt @@ -0,0 +1,55 @@ +package app.alextran.immich.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import es.antonborri.home_widget.HomeWidgetPlugin +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import app.alextran.immich.widget.model.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class RandomReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = PhotoWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + appWidgetIds.forEach { widgetID -> + ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) + } + } + + override fun onReceive(context: Context, intent: Intent) { + val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) + + // Launch coroutine to setup a single shot if the app requested the update + if (fromMainApp) { + CoroutineScope(Dispatchers.Default).launch { + val provider = ComponentName(context, RandomReceiver::class.java) + val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + + glanceIds.forEach { widgetID -> + ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM) + } + } + } + + super.onReceive(context, intent) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + CoroutineScope(Dispatchers.Default).launch { + appWidgetIds.forEach { id -> + ImageDownloadWorker.cancel(context, id) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt new file mode 100644 index 0000000000..74686ee0b8 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt @@ -0,0 +1,64 @@ +package app.alextran.immich.widget.configure + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* + + +data class DropdownItem ( + val label: String, + val id: String, +) + +// Creating a composable to display a drop down menu +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Dropdown(items: List, + selectedItem: DropdownItem?, + onItemSelected: (DropdownItem) -> Unit, + enabled: Boolean = true +) { + + var expanded by remember { mutableStateOf(false) } + var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded && enabled }, + ) { + + TextField( + value = selectedOption, + onValueChange = {}, + readOnly = true, + enabled = enabled, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + items.forEach { option -> + DropdownMenuItem( + text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) }, + onClick = { + selectedOption = option.label + onItemSelected(option) + + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt new file mode 100644 index 0000000000..efdcc41540 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt @@ -0,0 +1,28 @@ +package app.alextran.immich.widget.configure + +import android.os.Build +import androidx.compose.foundation.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun LightDarkTheme( + content: @Composable () -> Unit +) { + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme -> + dynamicDarkColorScheme(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme -> + dynamicLightColorScheme(context) + isDarkTheme -> darkColorScheme() + else -> lightColorScheme() + } + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt new file mode 100644 index 0000000000..d0490c023e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt @@ -0,0 +1,210 @@ +package app.alextran.immich.widget.configure + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import app.alextran.immich.widget.ImageDownloadWorker +import app.alextran.immich.widget.ImmichAPI +import app.alextran.immich.widget.model.* +import kotlinx.coroutines.launch +import java.io.FileNotFoundException + +class RandomConfigure : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get widget ID from intent + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + val glanceId = GlanceAppWidgetManager(applicationContext) + .getGlanceIdBy(appWidgetId) + + setContent { + LightDarkTheme { + RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = { + finish() + Log.w("WIDGET_ACTIVITY", "SAVING") + }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) { + + var selectedAlbum by remember { mutableStateOf(null) } + var showAlbumName by remember { mutableStateOf(false) } + var availableAlbums by remember { mutableStateOf>(listOf()) } + var state by remember { mutableStateOf(WidgetConfigState.LOADING) } + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + // get albums from server + val serverCfg = ImmichAPI.getServerConfig(context) + + if (serverCfg == null) { + state = WidgetConfigState.LOG_IN + return@LaunchedEffect + } + + val api = ImmichAPI(serverCfg) + + val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE" + val currentAlbumName = currentState[kSelectedAlbumName] ?: "None" + var albumItems: List + + try { + albumItems = api.fetchAlbums().map { + DropdownItem(it.albumName, it.id) + } + + state = WidgetConfigState.SUCCESS + } catch (e: FileNotFoundException) { + Log.e("WidgetWorker", "Error fetching albums: ${e.message}") + + state = WidgetConfigState.NO_CONNECTION + albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId)) + } + + availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems + + // load selected configuration + val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId } + selectedAlbum = albumEntity ?: availableAlbums.first() + + // load showAlbumName + showAlbumName = currentState[kShowAlbumName] == true + } + + suspend fun saveConfiguration() { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[kSelectedAlbum] = selectedAlbum?.id ?: "" + prefs[kSelectedAlbumName] = selectedAlbum?.label ?: "" + prefs[kShowAlbumName] = showAlbumName + } + + ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM) + } + + Scaffold( + topBar = { + TopAppBar ( + title = { Text("Widget Configuration") }, + actions = { + IconButton(onClick = { + scope.launch { + saveConfiguration() + onDone() + } + }) { + Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary) + } + } + ) + } + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), // Respect the top bar + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + when (state) { + WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp)) + WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.") + else -> { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium) + + // no connection warning + if (state == WidgetConfigState.NO_CONNECTION) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Warning", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "No connection to the server is available. Please try again later.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Album") + Dropdown( + items = availableAlbums, + selectedItem = selectedAlbum, + onItemSelected = { selectedAlbum = it }, + enabled = (state != WidgetConfigState.NO_CONNECTION) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Show Album Name") + Switch( + checked = showAlbumName, + onCheckedChange = { showAlbumName = it }, + enabled = (state != WidgetConfigState.NO_CONNECTION) + ) + } + } + } + } + } + } + } + } +} + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt new file mode 100644 index 0000000000..2337de0612 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt @@ -0,0 +1,79 @@ +package app.alextran.immich.widget.model + +import android.graphics.Bitmap +import androidx.datastore.preferences.core.* + +// MARK: Immich Entities + +enum class AssetType { + IMAGE, VIDEO, AUDIO, OTHER +} + +data class Asset( + val id: String, + val type: AssetType, +) + +data class SearchFilters( + var type: AssetType = AssetType.IMAGE, + val size: Int = 1, + var albumIds: List = listOf() +) + +data class MemoryResult( + val id: String, + var assets: List, + val type: String, + val data: MemoryData +) { + data class MemoryData(val year: Int) +} + +data class Album( + val id: String, + val albumName: String +) + +// MARK: Widget Specific + +enum class WidgetType { + RANDOM, MEMORIES; +} + +enum class WidgetState { + LOADING, SUCCESS, LOG_IN; +} + +enum class WidgetConfigState { + LOADING, SUCCESS, LOG_IN, NO_CONNECTION +} + +data class WidgetEntry ( + val image: Bitmap, + val subtitle: String?, + val deeplink: String? +) + +data class ServerConfig(val serverEndpoint: String, val sessionKey: String) + +// MARK: Widget State Keys +val kImageUUID = stringPreferencesKey("uuid") +val kSubtitleText = stringPreferencesKey("subtitle") +val kNow = longPreferencesKey("now") +val kWidgetState = stringPreferencesKey("state") +val kSelectedAlbum = stringPreferencesKey("albumID") +val kSelectedAlbumName = stringPreferencesKey("albumName") +val kShowAlbumName = booleanPreferencesKey("showAlbumName") +val kDeeplinkURL = stringPreferencesKey("deeplink") + +const val kWorkerWidgetType = "widgetType" +const val kWorkerWidgetID = "widgetId" +const val kTriggeredFromApp = "triggeredFromApp" + +fun imageFilename(id: String): String { + return "widget_image_$id.jpg" +} + +fun assetDeeplink(asset: Asset): String { + return "immich://asset?id=${asset.id}" +} diff --git a/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png new file mode 100644 index 0000000000..97aceb3ef6 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png differ diff --git a/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png new file mode 100644 index 0000000000..f94d1bbcd5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png differ diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..5ac495ebb5 --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Memories + Random + + See memories from Immich. + View a random image from your library or a specific album. + diff --git a/mobile/android/app/src/main/res/xml/memory_widget.xml b/mobile/android/app/src/main/res/xml/memory_widget.xml new file mode 100644 index 0000000000..611c5aae02 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/memory_widget.xml @@ -0,0 +1,9 @@ + diff --git a/mobile/android/app/src/main/res/xml/random_widget.xml b/mobile/android/app/src/main/res/xml/random_widget.xml new file mode 100644 index 0000000000..25fb24754f --- /dev/null +++ b/mobile/android/app/src/main/res/xml/random_widget.xml @@ -0,0 +1,13 @@ + diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 6d98152efc..c37498ea3e 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -28,7 +28,8 @@ const String appShareGroupId = "group.app.immich.share"; // add widget identifiers here for new widgets // these are used to force a widget refresh -const List kWidgetNames = [ - 'com.immich.widget.random', - 'com.immich.widget.memory', +// (iOSName, androidFQDN) +const List<(String, String)> kWidgetNames = [ + ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), + ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ]; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart index be314a281e..09532f4b78 100644 --- a/mobile/lib/repositories/widget.repository.dart +++ b/mobile/lib/repositories/widget.repository.dart @@ -10,8 +10,11 @@ class WidgetRepository { await HomeWidget.saveWidgetData(key, value); } - Future refresh(String name) async { - await HomeWidget.updateWidget(name: name, iOSName: name); + Future refresh(String iosName, String androidName) async { + await HomeWidget.updateWidget( + iOSName: iosName, + qualifiedAndroidName: androidName, + ); } Future setAppGroupId(String appGroupId) async { diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart index 02ddedbe89..fb2022784f 100644 --- a/mobile/lib/services/widget.service.dart +++ b/mobile/lib/services/widget.service.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/repositories/widget.repository.dart'; @@ -33,10 +32,8 @@ class WidgetService { } Future refreshWidgets() async { - if (Platform.isAndroid) return; - - for (final name in kWidgetNames) { - await _repository.refresh(name); + for (final (iOSName, androidName) in kWidgetNames) { + await _repository.refresh(iOSName, androidName); } } }