Feature: Home Screen Photo Widget#16524
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a configurable home screen widget that displays random photos from a selected Nextcloud folder. The widget supports offline fallback through cached thumbnails, recursive folder search, configurable refresh intervals (5/15/30/60 minutes or manual), and displays photo metadata (location and date) as an overlay. Users can manually cycle through photos using a "Next Image" button and tap photos to open the folder in the Nextcloud app.
Changes:
- Added photo widget infrastructure with configuration activity, provider, worker, and repository
- Implemented high-quality image fetching (2048px server preview, 800px widget display) with multi-tier caching strategy
- Integrated periodic and immediate background job scheduling for widget updates
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| PhotoWidgetProvider.kt | Widget provider handling lifecycle events and user interactions |
| PhotoWidgetWorker.kt | Background worker for fetching photos and updating all widget instances |
| PhotoWidgetRepository.kt | Data layer managing widget config persistence and image retrieval with caching |
| PhotoWidgetConfigActivity.kt | Configuration UI for folder selection and refresh interval picker |
| PhotoWidgetConfig.kt | Data model for per-widget configuration |
| PhotoWidgetRepositoryTest.kt | Unit tests for repository configuration management |
| BackgroundJobManager.kt | Interface additions for photo widget job scheduling |
| BackgroundJobManagerImpl.kt | Implementation of periodic and immediate widget update jobs |
| BackgroundJobFactory.kt | Worker factory integration for PhotoWidgetWorker |
| ComponentsModule.java | Dependency injection setup for widget components |
| AndroidManifest.xml | Widget provider and configuration activity registration |
| widget_photo.xml | Widget layout with image, metadata overlay, and next button |
| photo_widget_info.xml | Widget metadata configuration |
| ic_skip_next.xml | Next button icon drawable |
| strings.xml | String resources for widget UI |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| android:layout_marginBottom="8dp" | ||
| android:contentDescription="@string/photo_widget_next_image" | ||
| android:padding="6dp" | ||
| android:scaleType="fitCenter" |
There was a problem hiding this comment.
The "Next image" button lacks visual affordance or background, which may make it difficult to see against light-colored photos. Consider adding a semi-transparent background or using a Material Design FloatingActionButton style to ensure the button is always visible and clearly actionable.
| android:scaleType="fitCenter" | |
| android:scaleType="fitCenter" | |
| android:background="#80000000" | |
| android:tint="@android:color/white" |
| const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync" | ||
| const val JOB_PERIODIC_PHOTO_WIDGET = "periodic_photo_widget" | ||
| const val JOB_IMMEDIATE_PHOTO_WIDGET = "immediate_photo_widget" | ||
| const val PHOTO_WIDGET_INTERVAL_MINUTES = 15L |
There was a problem hiding this comment.
The constant PHOTO_WIDGET_INTERVAL_MINUTES is defined but never used in the code. The default interval is already defined in PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES and is used consistently throughout the code. This constant should either be removed or used in place of the hardcoded default value in the BackgroundJobManager interface (line 176).
| const val PHOTO_WIDGET_INTERVAL_MINUTES = 15L |
| val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) | ||
| photoWidgetRepository.saveWidgetConfig(config) | ||
|
|
||
| // Schedule periodic updates (or cancel if manual) |
There was a problem hiding this comment.
The schedulePeriodicPhotoWidgetUpdate method is called globally for all photo widgets, but each widget can have its own refresh interval configured by the user. When a new widget is added or configured with a different interval, it will replace the global periodic work schedule, potentially affecting all other widgets. Consider either: 1) using per-widget work requests with unique tags, 2) using the shortest interval among all widgets, or 3) documenting that all widgets share the same refresh schedule (last configured wins).
| // Schedule periodic updates (or cancel if manual) | |
| // Schedule periodic updates (or cancel if manual). | |
| // | |
| // NOTE: The periodic photo widget update is scheduled globally and shared | |
| // by all photo widgets. Calling this method updates the single shared | |
| // schedule, so the interval chosen here (for the most recently configured | |
| // widget) will apply to every photo widget ("last configured wins"). |
| private fun scaleBitmap(bitmap: Bitmap): Bitmap { | ||
| val width = bitmap.width | ||
| val height = bitmap.height | ||
|
|
||
| if (width <= MAX_BITMAP_DIMENSION && height <= MAX_BITMAP_DIMENSION) { | ||
| return bitmap | ||
| } | ||
|
|
||
| val ratio = width.toFloat() / height.toFloat() | ||
| val newWidth: Int | ||
| val newHeight: Int | ||
|
|
||
| if (width > height) { | ||
| newWidth = MAX_BITMAP_DIMENSION | ||
| newHeight = (MAX_BITMAP_DIMENSION / ratio).toInt() | ||
| } else { | ||
| newHeight = MAX_BITMAP_DIMENSION | ||
| newWidth = (MAX_BITMAP_DIMENSION * ratio).toInt() | ||
| } | ||
|
|
||
| return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) | ||
| } |
There was a problem hiding this comment.
When scaling down bitmaps, the original bitmap should be recycled if a new bitmap is created to avoid memory leaks. After calling Bitmap.createScaledBitmap, check if the returned bitmap is different from the input bitmap, and if so, call bitmap.recycle() on the original. This is especially important for large images that could be frequently loaded by the widget.
| @Suppress("DEPRECATION") | ||
| private fun resolveLocationName(latitude: Double?, longitude: Double?): String? { | ||
| if (latitude == null || longitude == null) return null | ||
| if (latitude == 0.0 && longitude == 0.0) return null | ||
|
|
||
| return try { | ||
| if (!Geocoder.isPresent()) return null | ||
| val geocoder = Geocoder(context, Locale.getDefault()) | ||
| val addresses = geocoder.getFromLocation(latitude, longitude, 1) |
There was a problem hiding this comment.
The Geocoder.getFromLocation method is deprecated in API 33+ and can block the main thread. Since this is running in a CoroutineWorker, consider using the newer getFromLocationAsync with a callback or using withContext(Dispatchers.IO) to ensure the deprecated synchronous call doesn't cause issues. Additionally, geocoding can be slow and may fail, which could impact widget refresh performance.
| super.onReceive(context, intent) | ||
| } | ||
|
|
||
| override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { |
There was a problem hiding this comment.
The injection call is missing in the onUpdate lifecycle method. Based on the pattern used in DashboardWidgetProvider (see DashboardWidgetProvider.kt:28), AndroidInjection.inject(this, context) should be called before using the injected dependencies.
| override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { | |
| override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { | |
| AndroidInjection.inject(this, context) |
| override fun onEnabled(context: Context) { | ||
| super.onEnabled(context) | ||
| backgroundJobManager.schedulePeriodicPhotoWidgetUpdate() | ||
| } | ||
|
|
||
| override fun onDisabled(context: Context) { | ||
| super.onDisabled(context) | ||
| backgroundJobManager.cancelPeriodicPhotoWidgetUpdate() | ||
| } | ||
|
|
||
| override fun onDeleted(context: Context, appWidgetIds: IntArray) { | ||
| super.onDeleted(context, appWidgetIds) | ||
| for (widgetId in appWidgetIds) { | ||
| photoWidgetRepository.deleteWidgetConfig(widgetId) | ||
| } | ||
| } |
There was a problem hiding this comment.
The injection call is missing in lifecycle methods. Based on the pattern in DashboardWidgetProvider (see DashboardWidgetProvider.kt:44,58), AndroidInjection.inject(this, context) should be called in onEnabled, onDisabled, and onDeleted before using injected dependencies.
| @Test | ||
| fun `saveWidgetConfig stores folder path and account name`() { | ||
| repository.saveWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME) | ||
|
|
||
| verify(editor).putString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(FOLDER_PATH)) | ||
| verify(editor).putString(eq("photo_widget_account_name_$WIDGET_ID"), eq(ACCOUNT_NAME)) | ||
| verify(editor).apply() | ||
| } |
There was a problem hiding this comment.
The test method signature is inconsistent with the production code. The production saveWidgetConfig method takes a PhotoWidgetConfig object (line 69 in PhotoWidgetRepository.kt), but the test calls it with three separate parameters (widgetId, folderPath, accountName). This test will not compile and should be updated to match the production signature.
|
|
||
| private fun createOpenFolderIntent(config: PhotoWidgetConfig?): Intent { | ||
| val intent = Intent(context, FileDisplayActivity::class.java) | ||
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) |
There was a problem hiding this comment.
The createOpenFolderIntent function doesn't utilize the config parameter, which contains the folder path that should be opened. The intent should include the folder path and account information so that clicking the photo actually opens the correct folder in FileDisplayActivity, as described in the PR description's testing section.
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) | |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) | |
| if (config != null) { | |
| // Pass folder path and account information so FileDisplayActivity | |
| // can open the correct folder for this widget configuration. | |
| intent.putExtra("folderPath", config.folderPath) | |
| intent.putExtra("accountName", config.accountName) | |
| } |
| override fun schedulePeriodicPhotoWidgetUpdate(intervalMinutes: Long) { | ||
| // Manual mode: cancel any existing periodic work | ||
| if (intervalMinutes <= 0L) { | ||
| cancelPeriodicPhotoWidgetUpdate() | ||
| return | ||
| } | ||
|
|
||
| val constraints = Constraints.Builder() | ||
| .setRequiredNetworkType(NetworkType.CONNECTED) | ||
| .build() | ||
|
|
||
| val request = periodicRequestBuilder( | ||
| jobClass = com.nextcloud.client.widget.photo.PhotoWidgetWorker::class, | ||
| jobName = JOB_PERIODIC_PHOTO_WIDGET, | ||
| intervalMins = intervalMinutes | ||
| ) | ||
| .setConstraints(constraints) | ||
| .build() | ||
|
|
||
| workManager.enqueueUniquePeriodicWork( | ||
| JOB_PERIODIC_PHOTO_WIDGET, | ||
| ExistingPeriodicWorkPolicy.REPLACE, | ||
| request | ||
| ) | ||
| } |
There was a problem hiding this comment.
The schedulePeriodicPhotoWidgetUpdate method requires network connectivity (line 837), but the PhotoWidgetRepository is designed to support offline fallback by using cached images. When users enable the widget and select a manual-only refresh interval, they may still expect cached images to rotate. Consider removing the network constraint from periodic updates or making it optional, as the repository can successfully serve cached images offline.
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…anagement and configuration. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…ates, and image display. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…photos with location and refresh functionality.
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…anagement and configuration. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…ates, and image display. Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
Signed-off-by: szyxxx <axeldavid1521@gmail.com>
…photos with location and refresh functionality.
e95f327 to
8daed6f
Compare
… debug build Signed-off-by: szyxxx <axeldavid1521@gmail.com>
… and retrieve random images.
…SL, including debug signing and various quality tools.
… handle widget updates, location display, and adaptive button tints.
Summary
This PR adds a configurable home screen widget that displays random photos from a selected Nextcloud folder. It supports offline fallback, recursive folder search, and custom refresh intervals.
Features
Memories/2023/).User Interface
FolderPickerActivity, followed by a native dialog for interval selection.Testing
Screenshots