diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e4da00c13d..e9b8a3b6d2 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -155,16 +155,16 @@ android:resource="@xml/widget" /> - - - - - - + + + + + + + + + + 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 index 615f42523a..b454fff677 100644 --- 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 @@ -16,23 +16,31 @@ package app.alextran.immich.widget * limitations under the License. */ +import HomeWidgetGlanceState +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION -import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION -import android.content.pm.PackageManager + import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper import android.util.Log -import androidx.core.content.FileProvider.getUriForFile import androidx.glance.* import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll +import androidx.glance.state.GlanceStateDefinition import androidx.work.* import com.google.gson.Gson +import es.antonborri.home_widget.HomeWidgetPlugin import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.random.Random class ImageDownloadWorker( @@ -44,53 +52,74 @@ class ImageDownloadWorker( private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName - fun enqueue(context: Context, glanceId: GlanceId, config: WidgetConfig) { + fun enqueue(context: Context, appWidgetId: Int, config: WidgetConfig) { val manager = WorkManager.getInstance(context) - val requestBuilder = OneTimeWorkRequestBuilder().apply { - addTag(glanceId.toString()) - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - setInputData( - Data.Builder() - .putString("config", Gson().toJson(config)) - .putInt("glanceId", glanceId.hashCode()) + + val workRequest = PeriodicWorkRequestBuilder( + 20, TimeUnit.MINUTES + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) - } + .setInputData( + Data.Builder() + .putString("config", Gson().toJson(config)) + .putInt("widgetId", appWidgetId) + .build() + ) + .addTag(appWidgetId.toString()) + .build() - manager.enqueueUniqueWork( - uniqueWorkName + glanceId.hashCode(), - ExistingWorkPolicy.KEEP, - requestBuilder.build() + manager.enqueueUniquePeriodicWork( + "$uniqueWorkName-$appWidgetId", + ExistingPeriodicWorkPolicy.UPDATE, + workRequest ) } - /** - * Cancel any ongoing worker - */ fun cancel(context: Context, glanceId: GlanceId) { - WorkManager.getInstance(context).cancelAllWorkByTag(glanceId.toString()) + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId) + WorkManager.getInstance(context).cancelAllWorkByTag(appWidgetId.toString()) } } + private fun getServerConfig(): ServerConfig? { + val prefs = HomeWidgetPlugin.getData(context) + + val serverURL = prefs.getString("widget_server_url", "") ?: "" + val sessionKey = prefs.getString("widget_auth_token", "") ?: "" + + if (serverURL == "" || sessionKey == "") { + return null + } + + return ServerConfig( + serverURL, + sessionKey + ) + } + override suspend fun doWork(): Result { return try { val configString = inputData.getString("config") val config = Gson().fromJson(configString, WidgetConfig::class.java) - val glanceId = inputData.getInt("glanceId", -1) + val widgetId = inputData.getInt("widgetId", -1) - if (glanceId == -1) { - Result.failure() + val serverConfig = getServerConfig() ?: return Result.success() + + val newBitmap = when (config.widgetType) { + WidgetType.RANDOM -> fetchRandom(serverConfig) } - fetchImage(config, glanceId) - updateWidget(config, glanceId) + saveImage(newBitmap, widgetId) + updateWidget(config, widgetId) Result.success() } catch (e: Exception) { Log.e(uniqueWorkName, "Error while loading image", e) if (runAttemptCount < 10) { - // Exponential backoff strategy will avoid the request to repeat - // too fast in case of failures. Result.retry() } else { Result.failure() @@ -98,29 +127,26 @@ class ImageDownloadWorker( } } - private suspend fun updateWidget(config: WidgetConfig, glanceId: Int) { + private suspend fun updateWidget(config: WidgetConfig, widgetID: Int) { val manager = GlanceAppWidgetManager(context) - val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass) + val glanceId = manager.getGlanceIdBy(widgetID) - for (id in glanceIds) { - if (id.hashCode() == glanceId) { - config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context) - break - } - } + RandomWidget().update(context, glanceId) + + Log.w("WIDGET_BG", "SENT THE UPDATE COMMAND: $widgetID") } - private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) { - val api = ImmichAPI(config.credentials) + private suspend fun fetchRandom(serverConfig: ServerConfig): Bitmap { + val api = ImmichAPI(serverConfig) val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1)) val image = api.fetchImage(random[0]) - saveImage(image, glanceId) + return image } - private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) { - val file = File(context.cacheDir, "widget_image_$glanceId.jpg") + private suspend fun saveImage(bitmap: Bitmap, widgetId: Int) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, "widget_image_$widgetId.jpg") 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 index d22a8a8a30..ec35a710ce 100644 --- 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 @@ -22,8 +22,7 @@ class ImmichAPI(cfg: ServerConfig) { private val serverConfig = cfg private fun buildRequestURL(endpoint: String, params: List> = emptyList()): URL { - val baseUrl = URL(serverConfig.serverEndpoint) - val urlString = StringBuilder("${baseUrl.protocol}://${baseUrl.host}:${baseUrl.port}$endpoint?sessionKey=${URLEncoder.encode(serverConfig.sessionKey, "UTF-8")}") + 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")}") diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt index ad2ba2ec8a..0a24f59001 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt @@ -1,14 +1,8 @@ package app.alextran.immich.widget +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.appwidget.GlanceAppWidget -sealed class WidgetError : Exception() { - data object NoLogin : WidgetError() - data object FetchFailed : WidgetError() - data object Unknown : WidgetError() - data object AlbumNotFound : WidgetError() -} - enum class AssetType { IMAGE, VIDEO, AUDIO, OTHER } @@ -50,7 +44,12 @@ enum class WidgetType { data class WidgetConfig( val widgetType: WidgetType, val params: Map, - val credentials: ServerConfig ) data class ServerConfig(val serverEndpoint: String, val sessionKey: String) + +// this value is in HomeWidgetPlugin.PREFERENCES but is marked internal +const val WIDGET_PREFERENCES = "HomeWidgetPreferences" + +val loggedInKey = stringPreferencesKey("isLoggedIn") +val lastUpdatedKey = stringPreferencesKey("lastUpdated") 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 index 614ba7458f..780e2b17a4 100644 --- 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 @@ -10,6 +10,7 @@ import androidx.glance.background import androidx.glance.layout.Box import androidx.glance.layout.ContentScale import androidx.glance.layout.fillMaxSize +import androidx.glance.text.Text import app.alextran.immich.R @Composable @@ -23,15 +24,17 @@ fun PhotoWidget(image: Bitmap?, error: String?, subtitle: String?) { if (image != null) { Image( provider = ImageProvider(image), - contentDescription = "Widget Image" + contentDescription = "Widget Image", + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize() ) } else { Image( provider = ImageProvider(R.drawable.splash), contentDescription = null, - contentScale = ContentScale.Crop, - modifier = GlanceModifier.fillMaxSize() ) + Text(subtitle ?: "NOPERS") + Text(error ?: "NOPERS") } } } 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 index 8e019c48e8..d2307bd3c6 100644 --- 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 @@ -1,8 +1,36 @@ package app.alextran.immich.widget import HomeWidgetGlanceWidgetReceiver +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit class RandomReceiver : HomeWidgetGlanceWidgetReceiver() { override val glanceAppWidget = RandomWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + val cfg = WidgetConfig(WidgetType.RANDOM, HashMap()) + + appWidgetIds.forEach { widgetID -> + ImageDownloadWorker.enqueue(context, widgetID, cfg) + Log.w("WIDGET_UPDATE", "WORKER ENQUEUE CALLED: $widgetID") + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt index e3de9421d1..ba88cf47cb 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt @@ -3,53 +3,38 @@ package app.alextran.immich.widget import HomeWidgetGlanceState import HomeWidgetGlanceStateDefinition import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.appwidget.* import androidx.glance.* -import androidx.glance.action.ActionParameters -import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.GlanceStateDefinition - +import java.io.File class RandomWidget : GlanceAppWidget() { override val stateDefinition: GlanceStateDefinition get() = HomeWidgetGlanceStateDefinition() override suspend fun provideGlance(context: Context, id: GlanceId) { - // fetch a random photo from server - provideContent { + Log.w("WIDGET_UPDATE", "PROVIDED GLANCE") + // fetch a random photo from server + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val file = File(context.cacheDir, "widget_image_$appWidgetId.jpg") - val prefs = currentState().preferences + var bitmap: Bitmap? = null - val serverURL = prefs.getString("widget_auth_token", "") - val sessionKey = prefs.getString("widget_auth_token", "") + if (file.exists()) { + bitmap = loadScaledBitmap(file, 500, 500) + } - - - PhotoWidget(image = null, error = null, subtitle = id.hashCode().toString()) - } + provideContent { + PhotoWidget(image = bitmap, error = null, subtitle = "hello") + } } + override suspend fun onDelete(context: Context, glanceId: GlanceId) { super.onDelete(context, glanceId) ImageDownloadWorker.cancel(context, glanceId) } } - -class RefreshAction : ActionCallback { - override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - // Clear the state to show loading screen - updateAppWidgetState(context, glanceId) { prefs -> - prefs.clear() - } - ImageGlanceWidget().update(context, glanceId) - - parameters. - - // Enqueue a job for each size the widget can be shown in the current state - // (i.e landscape/portrait) - GlanceAppWidgetManager(context).getAppWidgetSizes(glanceId).forEach { size -> - ImageWorker.enqueue(context, size, glanceId, force = true) - } - } -} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt deleted file mode 100644 index 94b8704650..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.alextran.immich.widget - - -data class WidgetEntry( - val imageURI: String? = null, - val error: WidgetError? = null, - val subtitle: String? = null -)