feat(widget): Implement Memories widget with photo display for Android App#1624
feat(widget): Implement Memories widget with photo display for Android App#1624szyxxx wants to merge 6 commits into
Conversation
…h functionality - Added MemoriesWidget class to manage widget updates and interactions. - Created WidgetWorker for background tasks to fetch photos from server or local storage. - Updated AndroidManifest.xml to declare the widget receiver and metadata. - Enhanced PhotoDao with new queries for random and "On This Day" photos. - Introduced drawable resources for widget UI elements including backgrounds and icons. - Designed widget layout in widget_memories.xml with image display, location text, and refresh button. - Added strings for widget descriptions and labels in strings.xml. - Configured widget info in widget_info.xml for layout and update settings. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR implements an Android home screen widget that displays photos from the Nextcloud Memories server. The widget features "On This Day" photo prioritization, offline caching, manual refresh functionality, and automatic updates via WorkManager. It supports resizable layouts from 2×2 to 6×6 cells and includes location display with EXIF geocoding.
Changes:
- Added widget configuration, layouts, and drawable resources for the Memories home screen widget
- Implemented WidgetWorker for photo fetching from server, local database, and MediaStore with 10-image offline cache
- Added PhotoDao queries for random photo selection and "On This Day" filtering
- Integrated WorkManager for 30-minute periodic updates with battery constraints
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| android/app/src/main/res/xml/widget_info.xml | Widget configuration with size constraints and update interval |
| android/app/src/main/res/values/strings.xml | Widget-specific string resources for UI labels and messages |
| android/app/src/main/res/layout/widget_memories.xml | Widget layout with photo display, text overlays, and refresh button |
| android/app/src/main/res/drawable/widget_refresh_circle.xml | Circular background for refresh button |
| android/app/src/main/res/drawable/widget_gradient_scrim_top.xml | Top gradient overlay for text readability |
| android/app/src/main/res/drawable/widget_gradient_scrim.xml | Bottom gradient overlay for text readability |
| android/app/src/main/res/drawable/widget_chip_background.xml | Unused chip background drawable |
| android/app/src/main/res/drawable/widget_background.xml | Rounded rectangle background for widget |
| android/app/src/main/res/drawable/ic_widget_refresh.xml | Refresh icon vector drawable |
| android/app/src/main/java/gallery/memories/widget/WidgetWorker.kt | Background worker handling photo fetching, caching, and widget updates |
| android/app/src/main/java/gallery/memories/widget/MemoriesWidget.kt | Widget provider managing lifecycle and WorkManager scheduling |
| android/app/src/main/java/gallery/memories/dao/PhotoDao.kt | Added queries for random photo selection and "On This Day" filtering |
| android/app/src/main/AndroidManifest.xml | Widget receiver registration with intent filters |
| android/app/build.gradle | Added WorkManager and Glide dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ) { | ||
| val loadSource: Any = systemImage.uri ?: systemImage.dataPath | ||
| val bitmap = suspendCoroutine<Bitmap?> { continuation -> | ||
| Glide.with(context) |
There was a problem hiding this comment.
Glide.with(context) is being called from a background Worker thread. While this works with application context, it's generally recommended to use Glide.with(context.applicationContext) explicitly in background threads to make the intent clear and avoid potential issues if the context reference changes. This is more of a defensive programming practice to ensure clarity and prevent subtle bugs.
| Glide.with(context) | |
| Glide.with(context.applicationContext) |
| private fun updateWidgetError(message: String) { | ||
| val appWidgetManager = AppWidgetManager.getInstance(context) | ||
| val appWidgetIds = appWidgetManager.getAppWidgetIds( | ||
| ComponentName(context, MemoriesWidget::class.java) | ||
| ) | ||
|
|
||
| for (appWidgetId in appWidgetIds) { | ||
| val views = RemoteViews(context.packageName, R.layout.widget_memories) | ||
| views.setTextViewText(R.id.widget_empty_text, message) | ||
| views.setViewVisibility(R.id.widget_empty_text, View.VISIBLE) | ||
| views.setViewVisibility(R.id.widget_image, View.GONE) | ||
| views.setViewVisibility(R.id.widget_label, View.GONE) | ||
| views.setViewVisibility(R.id.widget_date, View.GONE) | ||
| views.setViewVisibility(R.id.widget_location, View.GONE) | ||
| views.setViewVisibility(R.id.widget_scrim_top, View.GONE) | ||
|
|
||
| val intent = Intent(context, MainActivity::class.java) | ||
| val pendingIntent = PendingIntent.getActivity( | ||
| context, 0, intent, | ||
| PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||
| ) | ||
| views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) | ||
|
|
||
| appWidgetManager.updateAppWidget(appWidgetId, views) | ||
| } |
There was a problem hiding this comment.
When showing an error state, the refresh button functionality is not set up. Users tapping the refresh button in the error state won't be able to retry. Consider also setting up the refresh button's onClickPendingIntent in the error state to allow users to manually trigger a retry after an error.
| <?xml version="1.0" encoding="utf-8"?> | ||
| <shape xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:shape="rectangle"> | ||
| <corners android:radius="20dp" /> | ||
| <solid android:color="#B3000000" /> | ||
| </shape> |
There was a problem hiding this comment.
This drawable resource is defined but never used in the widget layout or code. Consider removing it if it's not needed, or use it if it was intended for the location text background or another UI element.
| private fun buildOkHttpClient(trustAll: Boolean): OkHttpClient { | ||
| return if (trustAll) { | ||
| val tm = object : X509TrustManager { | ||
| override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {} | ||
| override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {} | ||
| override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf() | ||
| } | ||
| val sc = SSLContext.getInstance("SSL") | ||
| sc.init(null, arrayOf(tm), SecureRandom()) | ||
| OkHttpClient.Builder() | ||
| .sslSocketFactory(sc.socketFactory, tm) | ||
| .hostnameVerifier { _, _ -> true } | ||
| .build() | ||
| } else { | ||
| OkHttpClient() | ||
| } | ||
| } |
There was a problem hiding this comment.
The buildOkHttpClient function creates a new OkHttpClient for each server photo fetch attempt. This is inefficient because OkHttpClient instances are expensive to create and should be reused. Consider creating a single OkHttpClient instance (or two instances: one for trustAll and one for regular) and reusing them across widget updates. This will improve performance and reduce resource consumption, especially important for a widget that updates frequently.
| private fun reverseGeocode(lat: Double, lon: Double): String? { | ||
| try { | ||
| @Suppress("DEPRECATION") | ||
| val addresses = Geocoder(context, Locale.getDefault()).getFromLocation(lat, lon, 1) |
There was a problem hiding this comment.
The reverseGeocode function uses the deprecated Geocoder.getFromLocation() method with a suppressWarnings annotation. Since this is new code being added, consider using the newer Geocoder.getFromLocation(latitude, longitude, maxResults, listener) API that takes a GeocodeListener callback (available from API 33+). Given the minSdk is 30, you could check the API level and use the newer async API when available, falling back to the synchronous version for older devices. This would be more future-proof and align with Android's current best practices.
| private fun getCacheDir(): File { | ||
| val dir = File(context.filesDir, CACHE_DIR) | ||
| if (!dir.exists()) dir.mkdirs() | ||
| return dir | ||
| } | ||
|
|
||
| /** | ||
| * Save a bitmap to the widget cache. Maintains a rolling window of | ||
| * MAX_CACHED images, deleting the oldest when the limit is exceeded. | ||
| */ | ||
| private fun cacheImage(bitmap: Bitmap, label: String) { | ||
| try { | ||
| val dir = getCacheDir() | ||
| val timestamp = System.currentTimeMillis() | ||
| val file = File(dir, "widget_${timestamp}_${label.hashCode()}.jpg") | ||
|
|
||
| FileOutputStream(file).use { out -> | ||
| bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) | ||
| } | ||
|
|
||
| // Prune old files if over limit | ||
| val files = dir.listFiles() | ||
| ?.filter { it.name.startsWith("widget_") && it.name.endsWith(".jpg") } | ||
| ?.sortedByDescending { it.lastModified() } | ||
| ?: return | ||
|
|
||
| if (files.size > MAX_CACHED) { | ||
| files.drop(MAX_CACHED).forEach { it.delete() } | ||
| } | ||
|
|
||
| Log.d(TAG, "Cached image: ${file.name} (${files.size.coerceAtMost(MAX_CACHED)} total)") | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Failed to cache image", e) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Load a random image from the cache. | ||
| */ | ||
| private fun loadCachedImage(): Bitmap? { | ||
| return try { | ||
| val dir = getCacheDir() | ||
| val files = dir.listFiles() | ||
| ?.filter { it.name.startsWith("widget_") && it.name.endsWith(".jpg") } | ||
| ?: return null | ||
|
|
||
| if (files.isEmpty()) return null | ||
|
|
||
| val file = files.random() | ||
| BitmapFactory.decodeFile(file.absolutePath) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Failed to load cached image", e) | ||
| null | ||
| } | ||
| } |
There was a problem hiding this comment.
The cache management in getCacheDir(), cacheImage(), and loadCachedImage() is not thread-safe. If multiple widget instances trigger updates simultaneously (e.g., user adds multiple widgets or forces refresh on multiple instances), concurrent access to the cache directory could lead to race conditions during file deletion or creation. Consider adding synchronization or using atomic file operations to prevent potential file corruption or inconsistent cache state.
| // Download the preview image | ||
| val previewResponse = client.newCall( | ||
| Request.Builder() | ||
| .url("${cred.url}api/image/preview/$fileId?x=1024&y=1024") |
There was a problem hiding this comment.
The preview image is requested at 1024x1024 pixels, which may be larger than necessary for widget display (especially for smaller widget sizes like 2x2). This results in unnecessary bandwidth usage and memory consumption. Consider calculating the appropriate preview size based on the actual widget dimensions or using a smaller default size like 512x512 for better performance, especially on metered connections.
|
|
||
| if (onThisDayCandidates.isNotEmpty()) { | ||
| // Weighted selection: 70% On This Day, 30% random | ||
| if (Math.random() < OTD_WEIGHT) { |
There was a problem hiding this comment.
Using Math.random() for probabilistic selection is not the most idiomatic Kotlin approach. Consider using Kotlin's Random.nextDouble() or kotlin.random.Random.Default.nextDouble() instead, which is more modern, testable, and thread-safe. This applies to both occurrences in this file.
| <ImageView | ||
| android:id="@+id/widget_scrim_top" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="72dp" | ||
| android:layout_gravity="top" | ||
| android:src="@drawable/widget_gradient_scrim_top" | ||
| android:scaleType="fitXY" | ||
| android:contentDescription="@null" | ||
| android:visibility="gone" /> | ||
|
|
||
| <!-- Bottom gradient scrim for text readability --> | ||
| <ImageView | ||
| android:id="@+id/widget_scrim" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="96dp" | ||
| android:layout_gravity="bottom" | ||
| android:src="@drawable/widget_gradient_scrim" | ||
| android:scaleType="fitXY" | ||
| android:contentDescription="@null" /> |
There was a problem hiding this comment.
The widget uses hardcoded dimensions (34dp, 72dp, 96dp, etc.) which may not scale well across different device densities and widget sizes. Consider using scalable dimensions or adjusting based on widget size. Additionally, the scrim heights are fixed and may not provide adequate text readability on larger widget sizes (6x6). Consider making these responsive to the actual widget dimensions.
| try { | ||
| val infoResponse = client.newCall( | ||
| Request.Builder() | ||
| .url("${cred.url}api/image/info/$fileId") | ||
| .header("Authorization", authHeader) | ||
| .header("User-Agent", "MemoriesNative/1.0") | ||
| .header("OCS-APIRequest", "true") | ||
| .header("X-Requested-With", "gallery.memories") | ||
| .get() | ||
| .build() | ||
| ).execute() | ||
|
|
||
| if (infoResponse.code == 200) { | ||
| val infoBody = infoResponse.body.string() | ||
| val infoJson = JSONObject(infoBody) | ||
| if (infoJson.has("address") && !infoJson.isNull("address")) { | ||
| locationText = infoJson.getString("address") | ||
| } | ||
| } | ||
| infoResponse.body.close() | ||
| } catch (e: Exception) { | ||
| Log.w(TAG, "Failed to fetch photo info for location", e) | ||
| } |
There was a problem hiding this comment.
When infoResponse fails (not code 200), the response body is not explicitly closed before the catch block or return. At line 313, the body is only closed after successful processing. If the response code is not 200, execution continues without closing the body, potentially leading to resource leaks. Add an else branch or use a try-finally pattern to ensure the body is always closed.
…wable resource Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…ts and remove automatic update interval Signed-off-by: szyxxx <axeldavid1521@gmail.com>
- Added strings for memory-related features in strings.xml: - "From your memories" - "Throwback" - "Remember this?" - "Rediscover" - Updated gradle.properties to disable certain features and streamline build configuration. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…oto widget Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
|
@szyxxx this is awesome. i just created the same thing for my self. Will you continue on this? please do! |
Thanks! i definitely plan on continuing it, there are still a few things i want to polish like the offline caching and UI. Since you built a version for yourself, I'd love to hear if you have any feedback or essential features in mind |
Summary
Adds an Android home screen widget that displays photos from the Nextcloud server.
Features
Screenshots
Testing