diff --git a/app/src/main/java/com/immichframe/immichframe/Helpers.kt b/app/src/main/java/com/immichframe/immichframe/Helpers.kt index 1ff4786..f0b052d 100644 --- a/app/src/main/java/com/immichframe/immichframe/Helpers.kt +++ b/app/src/main/java/com/immichframe/immichframe/Helpers.kt @@ -114,45 +114,43 @@ object Helpers { } data class ImageResponse( - val randomImageBase64: String, - val thumbHashImageBase64: String, - val photoDate: String, - val imageLocation: String + val randomImageBase64: String? = null, + val thumbHashImageBase64: String? = null, + val photoDate: String? = null, + val imageLocation: String? = null ) data class ServerSettings( - val margin: String, - val interval: Int, - val transitionDuration: Double, - val downloadImages: Boolean, - val renewImagesDuration: Int, - val showClock: Boolean, - val clockFormat: String, - val showPhotoDate: Boolean, - val photoDateFormat: String, - val showImageDesc: Boolean, - val showPeopleDesc: Boolean, - val showImageLocation: Boolean, - val imageLocationFormat: String, - val primaryColor: String?, - val secondaryColor: String, - val style: String, - val baseFontSize: String?, - val showWeatherDescription: Boolean, - val unattendedMode: Boolean, - val imageZoom: Boolean, - val imageFill: Boolean, - val layout: String, - val language: String + val interval: Int = 10, + val transitionDuration: Double = 1.0, + val downloadImages: Boolean = false, + val renewImagesDuration: Int = 0, + val showClock: Boolean = false, + val clockFormat: String? = null, + val showPhotoDate: Boolean = false, + val photoDateFormat: String? = null, + val showImageDesc: Boolean = false, + val showPeopleDesc: Boolean = false, + val showImageLocation: Boolean = false, + val imageLocationFormat: String? = null, + val primaryColor: String? = null, + val secondaryColor: String? = null, + val style: String? = null, + val baseFontSize: String? = null, + val showWeatherDescription: Boolean = false, + val imageZoom: Boolean = false, + val imageFill: Boolean = false, + val layout: String? = null, + val language: String? = null ) data class Weather( - val location: String, - val temperature: Double, - val unit: String, - val temperatureUnit: String, - val description: String, - val iconId: String + val location: String? = null, + val temperature: Double = 0.0, + val unit: String? = null, + val temperatureUnit: String? = null, + val description: String? = null, + val iconId: String? = null ) interface ApiService { @@ -162,6 +160,9 @@ object Helpers { @GET("api/Config") fun getServerSettings(): Call + @GET("api/Config/Version") + fun getVersion(): Call + @GET("api/Weather") fun getWeather(): Call } diff --git a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt index 4cde6f2..93ae9e9 100644 --- a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt +++ b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt @@ -178,21 +178,27 @@ class MainActivity : AppCompatActivity() { private fun showImage(imageResponse: Helpers.ImageResponse) { CoroutineScope(Dispatchers.IO).launch { - //get the window size + val imageBase64 = imageResponse.randomImageBase64 + val thumbHashBase64 = imageResponse.thumbHashImageBase64 + if (imageBase64 == null || thumbHashBase64 == null) { + return@launch + } + val decorView = window.decorView val width = decorView.width val height = decorView.height val maxSize = maxOf(width, height) - var randomBitmap = Helpers.decodeBitmapFromBytes(imageResponse.randomImageBase64) - val thumbHashBitmap = Helpers.decodeBitmapFromBytes(imageResponse.thumbHashImageBase64) + var randomBitmap = Helpers.decodeBitmapFromBytes(imageBase64) + val thumbHashBitmap = Helpers.decodeBitmapFromBytes(thumbHashBase64) var isMerged = false val isPortrait = randomBitmap.height > randomBitmap.width if (isPortrait && serverSettings.layout == "splitview") { - if (portraitCache != null) { + val localPortrait = portraitCache + if (localPortrait?.randomImageBase64 != null) { var decodedPortraitImageBitmap = - Helpers.decodeBitmapFromBytes(portraitCache!!.randomImageBase64) + Helpers.decodeBitmapFromBytes(localPortrait.randomImageBase64) decodedPortraitImageBitmap = Helpers.reduceBitmapQuality(decodedPortraitImageBitmap, maxSize) randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, maxSize) @@ -265,24 +271,23 @@ class MainActivity : AppCompatActivity() { isShowingFirst = !isShowingFirst if (isMerged) { - val mergedPhotoDate = - if (portraitCache!!.photoDate.isNotEmpty() || imageResponse.photoDate.isNotEmpty()) { - "${portraitCache!!.photoDate} | ${imageResponse.photoDate}" - } else { - "" - } + val cachedPhotoDate = portraitCache?.photoDate.orEmpty() + val cachedImageLocation = portraitCache?.imageLocation.orEmpty() + val responsePhotoDate = imageResponse.photoDate.orEmpty() + val responseImageLocation = imageResponse.imageLocation.orEmpty() - val mergedImageLocation = - if (portraitCache!!.imageLocation.isNotEmpty() || imageResponse.imageLocation.isNotEmpty()) { - "${portraitCache!!.imageLocation} | ${imageResponse.imageLocation}" - } else { - "" - } + val mergedPhotoDate = listOf(cachedPhotoDate, responsePhotoDate) + .filter { it.isNotEmpty() } + .joinToString(" | ") + + val mergedImageLocation = listOf(cachedImageLocation, responseImageLocation) + .filter { it.isNotEmpty() } + .joinToString(" | ") updatePhotoInfo(mergedPhotoDate, mergedImageLocation) portraitCache = null } else { - updatePhotoInfo(imageResponse.photoDate, imageResponse.imageLocation) + updatePhotoInfo(imageResponse.photoDate.orEmpty(), imageResponse.imageLocation.orEmpty()) } updateDateTimeWeather() @@ -308,17 +313,17 @@ class MainActivity : AppCompatActivity() { val currentDateTime = Calendar.getInstance().time val formattedDate = try { - SimpleDateFormat(serverSettings.photoDateFormat, Locale.getDefault()).format( - currentDateTime - ) + serverSettings.photoDateFormat?.let { + SimpleDateFormat(it, Locale.getDefault()).format(currentDateTime) + } ?: "" } catch (_: Exception) { "" } val formattedTime = try { - SimpleDateFormat(serverSettings.clockFormat, Locale.getDefault()).format( - currentDateTime - ) + serverSettings.clockFormat?.let { + SimpleDateFormat(it, Locale.getDefault()).format(currentDateTime) + } ?: "" } catch (_: Exception) { "" } @@ -418,8 +423,11 @@ class MainActivity : AppCompatActivity() { if (response.isSuccessful) { val weatherResponse = response.body() if (weatherResponse != null) { - currentWeather = - "\n ${weatherResponse.location}, ${"%.1f".format(weatherResponse.temperature)}${weatherResponse.unit} \n ${weatherResponse.description}" + val loc = weatherResponse.location ?: "Unknown" + val temp = weatherResponse.temperature + val unit = weatherResponse.unit ?: "" + val desc = weatherResponse.description ?: "" + currentWeather = "\n $loc, ${"%.1f".format(temp)}$unit \n $desc" } } } @@ -452,29 +460,41 @@ class MainActivity : AppCompatActivity() { if (serverSettingsResponse != null) { onSuccess(serverSettingsResponse) } else { - handleFailure(Exception("Empty response body")) + handleFailure(Exception("Empty response body"), retryable = true) } } else { - handleFailure(Exception("HTTP ${response.code()}: ${response.message()}")) + val code = response.code() + val retryable = code !in 400..499 + val hint = when (code) { + 404 -> "Endpoint not found. Make sure the URL points to an ImmichFrame server, not the Immich server directly." + 401, 403 -> "Authentication failed. Check your Authorization Secret in settings." + else -> null + } + val msg = if (hint != null) "$hint (HTTP $code)" else "HTTP $code: ${response.message()}" + handleFailure(Exception(msg), retryable = retryable) } } override fun onFailure(call: Call, t: Throwable) { - handleFailure(t) + handleFailure(t, retryable = true) } - private fun handleFailure(t: Throwable) { + private fun handleFailure(t: Throwable, retryable: Boolean) { if (useWebView) { return } + if (!retryable) { + onFailure(t) + return + } if (retryCount < maxRetries) { retryCount++ Toast.makeText( this@MainActivity, - "Retrying to fetch server settings... Attempt $retryCount of $maxRetries", + "Connecting to server... Attempt $retryCount of $maxRetries", Toast.LENGTH_SHORT ).show() - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ attemptFetch() }, retryDelayMillis) } else { @@ -556,13 +576,13 @@ class MainActivity : AppCompatActivity() { if (request?.isForMainFrame == true && error != null) { view?.loadUrl("file:///android_asset/error_page.html") - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ val errorCode = error.errorCode val errorDescription = error.description.toString().replace("'", "\\'") view?.evaluateJavascript("showError('$errorCode', '$errorDescription')", null) }, 500) } - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ //check url again in case the user has changed it var currentUrl = prefs.getString("webview_url", "")?.trim() ?: "" currentUrl = if (authSecret.isNotEmpty()) { @@ -596,7 +616,7 @@ class MainActivity : AppCompatActivity() { Toast.makeText( this, "Failed to load server settings: ${error.localizedMessage}", - Toast.LENGTH_SHORT + Toast.LENGTH_LONG ).show() } ) diff --git a/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt b/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt index 97cf85e..40cea90 100644 --- a/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt +++ b/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt @@ -168,21 +168,27 @@ class ScreenSaverService : DreamService() { private fun showImage(imageResponse: Helpers.ImageResponse) { CoroutineScope(Dispatchers.IO).launch { - //get the window size + val imageBase64 = imageResponse.randomImageBase64 + val thumbHashBase64 = imageResponse.thumbHashImageBase64 + if (imageBase64 == null || thumbHashBase64 == null) { + return@launch + } + val decorView = window.decorView val width = decorView.width val height = decorView.height val maxSize = maxOf(width, height) - var randomBitmap = Helpers.decodeBitmapFromBytes(imageResponse.randomImageBase64) - val thumbHashBitmap = Helpers.decodeBitmapFromBytes(imageResponse.thumbHashImageBase64) + var randomBitmap = Helpers.decodeBitmapFromBytes(imageBase64) + val thumbHashBitmap = Helpers.decodeBitmapFromBytes(thumbHashBase64) var isMerged = false val isPortrait = randomBitmap.height > randomBitmap.width if (isPortrait && serverSettings.layout == "splitview") { - if (portraitCache != null) { + val localPortrait = portraitCache + if (localPortrait?.randomImageBase64 != null) { var decodedPortraitImageBitmap = - Helpers.decodeBitmapFromBytes(portraitCache!!.randomImageBase64) + Helpers.decodeBitmapFromBytes(localPortrait.randomImageBase64) decodedPortraitImageBitmap = Helpers.reduceBitmapQuality(decodedPortraitImageBitmap, maxSize) randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, maxSize) @@ -255,24 +261,23 @@ class ScreenSaverService : DreamService() { isShowingFirst = !isShowingFirst if (isMerged) { - val mergedPhotoDate = - if (portraitCache!!.photoDate.isNotEmpty() || imageResponse.photoDate.isNotEmpty()) { - "${portraitCache!!.photoDate} | ${imageResponse.photoDate}" - } else { - "" - } + val cachedPhotoDate = portraitCache?.photoDate.orEmpty() + val cachedImageLocation = portraitCache?.imageLocation.orEmpty() + val responsePhotoDate = imageResponse.photoDate.orEmpty() + val responseImageLocation = imageResponse.imageLocation.orEmpty() - val mergedImageLocation = - if (portraitCache!!.imageLocation.isNotEmpty() || imageResponse.imageLocation.isNotEmpty()) { - "${portraitCache!!.imageLocation} | ${imageResponse.imageLocation}" - } else { - "" - } + val mergedPhotoDate = listOf(cachedPhotoDate, responsePhotoDate) + .filter { it.isNotEmpty() } + .joinToString(" | ") + + val mergedImageLocation = listOf(cachedImageLocation, responseImageLocation) + .filter { it.isNotEmpty() } + .joinToString(" | ") updatePhotoInfo(mergedPhotoDate, mergedImageLocation) portraitCache = null } else { - updatePhotoInfo(imageResponse.photoDate, imageResponse.imageLocation) + updatePhotoInfo(imageResponse.photoDate.orEmpty(), imageResponse.imageLocation.orEmpty()) } updateDateTimeWeather() @@ -298,17 +303,17 @@ class ScreenSaverService : DreamService() { val currentDateTime = Calendar.getInstance().time val formattedDate = try { - SimpleDateFormat(serverSettings.photoDateFormat, Locale.getDefault()).format( - currentDateTime - ) + serverSettings.photoDateFormat?.let { + SimpleDateFormat(it, Locale.getDefault()).format(currentDateTime) + } ?: "" } catch (_: Exception) { "" } val formattedTime = try { - SimpleDateFormat(serverSettings.clockFormat, Locale.getDefault()).format( - currentDateTime - ) + serverSettings.clockFormat?.let { + SimpleDateFormat(it, Locale.getDefault()).format(currentDateTime) + } ?: "" } catch (_: Exception) { "" } @@ -396,8 +401,10 @@ class ScreenSaverService : DreamService() { if (response.isSuccessful) { val weatherResponse = response.body() if (weatherResponse != null) { - currentWeather = - "\n ${weatherResponse.location}, ${weatherResponse.temperatureUnit} \n ${weatherResponse.description}" + val loc = weatherResponse.location ?: "Unknown" + val unit = weatherResponse.temperatureUnit ?: "" + val desc = weatherResponse.description ?: "" + currentWeather = "\n $loc, $unit \n $desc" } } } @@ -427,26 +434,38 @@ class ScreenSaverService : DreamService() { if (serverSettingsResponse != null) { onSuccess(serverSettingsResponse) } else { - handleFailure(Exception("Empty response body")) + handleFailure(Exception("Empty response body"), retryable = true) } } else { - handleFailure(Exception("HTTP ${response.code()}: ${response.message()}")) + val code = response.code() + val retryable = code !in 400..499 + val hint = when (code) { + 404 -> "Endpoint not found. Make sure the URL points to an ImmichFrame server, not the Immich server directly." + 401, 403 -> "Authentication failed. Check your Authorization Secret in settings." + else -> null + } + val msg = if (hint != null) "$hint (HTTP $code)" else "HTTP $code: ${response.message()}" + handleFailure(Exception(msg), retryable = retryable) } } override fun onFailure(call: Call, t: Throwable) { - handleFailure(t) + handleFailure(t, retryable = true) } - private fun handleFailure(t: Throwable) { + private fun handleFailure(t: Throwable, retryable: Boolean) { + if (!retryable) { + onFailure(t) + return + } if (retryCount < maxRetries) { retryCount++ Toast.makeText( this@ScreenSaverService, - "Retrying to fetch server settings... Attempt $retryCount of $maxRetries", + "Connecting to server... Attempt $retryCount of $maxRetries", Toast.LENGTH_SHORT ).show() - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ attemptFetch() }, retryDelayMillis) } else { @@ -511,13 +530,13 @@ class ScreenSaverService : DreamService() { if (request?.isForMainFrame == true && error != null) { view?.loadUrl("file:///android_asset/error_page.html") - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ val errorCode = error.errorCode val errorDescription = error.description.toString().replace("'", "\\'") view?.evaluateJavascript("showError('$errorCode', '$errorDescription')", null) }, 500) } - Handler(Looper.getMainLooper()).postDelayed({ + handler.postDelayed({ webView.loadUrl(savedUrl) }, 5000) } @@ -538,7 +557,7 @@ class ScreenSaverService : DreamService() { Toast.makeText( this, "Failed to load server settings: ${error.localizedMessage}", - Toast.LENGTH_SHORT + Toast.LENGTH_LONG ).show() } ) diff --git a/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt b/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt index 9770612..8d6dd95 100644 --- a/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt +++ b/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt @@ -84,19 +84,22 @@ class WidgetProvider : AppWidgetProvider() { CoroutineScope(Dispatchers.IO).launch { getNextImage(apiService) { imageResponse -> imageResponse?.let { - CoroutineScope(Dispatchers.IO).launch { - var randomBitmap = - Helpers.decodeBitmapFromBytes(it.randomImageBase64) - - //randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, maxSize) - randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, 1000) - - withContext(Dispatchers.Main) { - views.setImageViewBitmap( - R.id.widgetImageView, - randomBitmap - ) - appWidgetManager.updateAppWidget(appWidgetId, views) + val base64 = it.randomImageBase64 + if (base64 != null) { + CoroutineScope(Dispatchers.IO).launch { + var randomBitmap = + Helpers.decodeBitmapFromBytes(base64) + + //randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, maxSize) + randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, 1000) + + withContext(Dispatchers.Main) { + views.setImageViewBitmap( + R.id.widgetImageView, + randomBitmap + ) + appWidgetManager.updateAppWidget(appWidgetId, views) + } } } }