Skip to content

feat(widget): Implement Memories widget with photo display for Android App#1624

Open
szyxxx wants to merge 6 commits into
pulsejet:masterfrom
szyxxx:master
Open

feat(widget): Implement Memories widget with photo display for Android App#1624
szyxxx wants to merge 6 commits into
pulsejet:masterfrom
szyxxx:master

Conversation

@szyxxx

@szyxxx szyxxx commented Feb 18, 2026

Copy link
Copy Markdown

Summary

Adds an Android home screen widget that displays photos from the Nextcloud server.

Features

  • Fetches photos from the Nextcloud Memories API
  • "On This Day" prioritization with random photo fallback
  • Offline mode with 10-image local cache
  • Resizable from 2×2 to 6×6
  • Manual refresh button
  • WorkManager-based 30-minute auto-refresh

Screenshots

image image

Testing

  • Tested on Android emulator (API 35)
  • Verified server fetch, offline cache fallback, widget resize

…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>
Copilot AI review requested due to automatic review settings February 18, 2026 07:49
@szyxxx szyxxx changed the title feat(widget): Implement Memories widget with photo display and refres… feat(widget): Implement Memories widget with photo display Feb 18, 2026
@szyxxx szyxxx changed the title feat(widget): Implement Memories widget with photo display feat(widget): Implement Memories widget with photo display for Android App Feb 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Glide.with(context)
Glide.with(context.applicationContext)

Copilot uses AI. Check for mistakes.
Comment on lines +620 to +644
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)
}

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
<?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>

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +362 to +378
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()
}
}

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +671 to +674
private fun reverseGeocode(lat: Double, lon: Double): String? {
try {
@Suppress("DEPRECATION")
val addresses = Geocoder(context, Locale.getDefault()).getFromLocation(lat, lon, 1)

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +146
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
}
}

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// Download the preview image
val previewResponse = client.newCall(
Request.Builder()
.url("${cred.url}api/image/preview/$fileId?x=1024&y=1024")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

if (onThisDayCandidates.isNotEmpty()) {
// Weighted selection: 70% On This Day, 30% random
if (Math.random() < OTD_WEIGHT) {

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +37
<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" />

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +294 to +316
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)
}

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
…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>
@thrillfall

Copy link
Copy Markdown

@szyxxx this is awesome. i just created the same thing for my self. Will you continue on this? please do!

@szyxxx

szyxxx commented Apr 25, 2026

Copy link
Copy Markdown
Author

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants