From 6da72df07f8534d131b724e42e5166a6dfd1b43f Mon Sep 17 00:00:00 2001 From: szyxxx Date: Mon, 16 Feb 2026 17:28:23 +0700 Subject: [PATCH 01/20] feat: implement a new photo widget to display Nextcloud photos. Signed-off-by: szyxxx --- app/src/main/AndroidManifest.xml | 20 ++ .../nextcloud/client/di/ComponentsModule.java | 8 + .../client/jobs/BackgroundJobFactory.kt | 14 +- .../client/jobs/BackgroundJobManager.kt | 5 + .../client/jobs/BackgroundJobManagerImpl.kt | 54 +++++ .../client/widget/photo/PhotoWidgetConfig.kt | 24 ++ .../widget/photo/PhotoWidgetConfigActivity.kt | 149 ++++++++++++ .../widget/photo/PhotoWidgetProvider.kt | 66 ++++++ .../widget/photo/PhotoWidgetRepository.kt | 221 ++++++++++++++++++ .../client/widget/photo/PhotoWidgetWorker.kt | 150 ++++++++++++ app/src/main/res/drawable/ic_skip_next.xml | 16 ++ app/src/main/res/layout/widget_photo.xml | 82 +++++++ app/src/main/res/values/strings.xml | 13 ++ app/src/main/res/xml/photo_widget_info.xml | 17 ++ .../widget/photo/PhotoWidgetRepositoryTest.kt | 117 ++++++++++ 15 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt create mode 100644 app/src/main/res/drawable/ic_skip_next.xml create mode 100644 app/src/main/res/layout/widget_photo.xml create mode 100644 app/src/main/res/xml/photo_widget_info.xml create mode 100644 app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20c10607b707..2b8b9772790d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -332,6 +332,26 @@ android:resource="@xml/dashboard_widget_info" /> + + + + + + + + + + + + + + , private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, - private val database: NextcloudDatabase + private val database: NextcloudDatabase, + private val photoWidgetRepository: Provider ) : WorkerFactory() { @SuppressLint("NewApi") @@ -104,6 +105,7 @@ class BackgroundJobFactory @Inject constructor( InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters) MetadataWorker::class -> createMetadataWorker(context, workerParameters) FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters) + com.nextcloud.client.widget.photo.PhotoWidgetWorker::class -> createPhotoWidgetWorker(context, workerParameters) else -> null // caller falls back to default factory } } @@ -299,4 +301,14 @@ class BackgroundJobFactory @Inject constructor( viewThemeUtils.get(), params ) + private fun createPhotoWidgetWorker( + context: Context, + params: WorkerParameters + ): com.nextcloud.client.widget.photo.PhotoWidgetWorker = + com.nextcloud.client.widget.photo.PhotoWidgetWorker( + context, + params, + photoWidgetRepository.get(), + accountManager + ) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 0dfef42aba27..6531da51e8b9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -171,4 +171,9 @@ interface BackgroundJobManager { fun startMetadataSyncJob(currentDirPath: String) fun downloadFolder(folder: OCFile, accountName: String) fun cancelFolderDownload() + + // Photo widget + fun schedulePeriodicPhotoWidgetUpdate(intervalMinutes: Long = 15L) + fun startImmediatePhotoWidgetUpdate() + fun cancelPeriodicPhotoWidgetUpdate() } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index c5f6ceb021d4..2ca8528836b4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -99,6 +99,9 @@ internal class BackgroundJobManagerImpl( const val JOB_DOWNLOAD_FOLDER = "download_folder" const val JOB_METADATA_SYNC = "metadata_sync" 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 const val JOB_TEST = "test_job" @@ -820,4 +823,55 @@ internal class BackgroundJobManagerImpl( override fun cancelFolderDownload() { workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER) } + + // --------------- Photo Widget --------------- + + 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 + ) + } + + override fun startImmediatePhotoWidgetUpdate() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = oneTimeRequestBuilder( + com.nextcloud.client.widget.photo.PhotoWidgetWorker::class, + JOB_IMMEDIATE_PHOTO_WIDGET + ) + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork( + JOB_IMMEDIATE_PHOTO_WIDGET, + ExistingWorkPolicy.REPLACE, + request + ) + } + + override fun cancelPeriodicPhotoWidgetUpdate() { + workManager.cancelJob(JOB_PERIODIC_PHOTO_WIDGET) + } } diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt new file mode 100644 index 000000000000..165630b00cb5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +/** + * Holds per-widget configuration for the Photo Widget. + * + * @param intervalMinutes Refresh interval in minutes. 0 means manual-only (no auto-refresh). + */ +data class PhotoWidgetConfig( + val widgetId: Int, + val folderPath: String, + val accountName: String, + val intervalMinutes: Long = DEFAULT_INTERVAL_MINUTES +) { + companion object { + const val DEFAULT_INTERVAL_MINUTES = 15L + val INTERVAL_OPTIONS = longArrayOf(5L, 15L, 30L, 60L, 0L) // 0 = manual + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt new file mode 100644 index 000000000000..2f79d7173b4b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.app.Activity +import android.app.AlertDialog +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Build +import android.os.Bundle +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FolderPickerActivity +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * Configuration activity launched when the user places a Photo Widget. + * + * Opens [FolderPickerActivity] for folder selection, then shows an interval + * picker dialog, saves the config, and triggers an immediate widget update. + */ +class PhotoWidgetConfigActivity : Activity() { + + companion object { + private const val REQUEST_FOLDER_PICKER = 1001 + } + + @Inject + lateinit var photoWidgetRepository: PhotoWidgetRepository + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var userAccountManager: UserAccountManager + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + + // Set result to CANCELED in case the user backs out + setResult(RESULT_CANCELED) + + // Extract the widget ID from the intent + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + // Launch FolderPickerActivity for folder selection + val folderPickerIntent = Intent(this, FolderPickerActivity::class.java).apply { + putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + } + @Suppress("DEPRECATION") + startActivityForResult(folderPickerIntent, REQUEST_FOLDER_PICKER) + } + + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQUEST_FOLDER_PICKER && resultCode == RESULT_OK && data != null) { + val folder: OCFile? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER, OCFile::class.java) + } else { + @Suppress("DEPRECATION") + data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER) + } + + if (folder != null) { + showIntervalPicker(folder) + } else { + finish() + } + } else { + finish() + } + } + + /** + * Shows a dialog for the user to pick a refresh interval. + * Presets: 5 / 15 / 30 / 60 minutes / Manual only. + */ + private fun showIntervalPicker(selectedFolder: OCFile) { + val labels = arrayOf( + getString(R.string.photo_widget_interval_5), + getString(R.string.photo_widget_interval_15), + getString(R.string.photo_widget_interval_30), + getString(R.string.photo_widget_interval_60), + getString(R.string.photo_widget_interval_manual) + ) + val values = PhotoWidgetConfig.INTERVAL_OPTIONS // [5, 15, 30, 60, 0] + + // Default selection: 15 minutes (index 1) + var selectedIndex = 1 + + AlertDialog.Builder(this) + .setTitle(getString(R.string.photo_widget_interval_title)) + .setSingleChoiceItems(labels, selectedIndex) { _, which -> + selectedIndex = which + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val intervalMinutes = values[selectedIndex] + finishConfiguration(selectedFolder, intervalMinutes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + finish() + } + .setOnCancelListener { + finish() + } + .show() + } + + private fun finishConfiguration(folder: OCFile, intervalMinutes: Long) { + val folderPath = folder.remotePath + val accountName = userAccountManager.user.accountName + + // Save configuration (including interval) + photoWidgetRepository.saveWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + + // Schedule periodic updates (or cancel if manual) + backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) + + // Trigger immediate widget update + backgroundJobManager.startImmediatePhotoWidgetUpdate() + + // Return success + val resultValue = Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + setResult(RESULT_OK, resultValue) + finish() + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt new file mode 100644 index 000000000000..6ba58f19efd2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import com.nextcloud.client.jobs.BackgroundJobManager +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * App widget provider for the Photo Widget. + * + * Delegates heavy work to [PhotoWidgetWorker] via [BackgroundJobManager]. + */ +class PhotoWidgetProvider : AppWidgetProvider() { + + companion object { + const val ACTION_NEXT_IMAGE = "com.nextcloud.client.widget.photo.ACTION_NEXT_IMAGE" + } + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var photoWidgetRepository: PhotoWidgetRepository + + override fun onReceive(context: Context, intent: Intent?) { + AndroidInjection.inject(this, context) + + // Handle "next image" button tap + if (intent?.action == ACTION_NEXT_IMAGE) { + backgroundJobManager.startImmediatePhotoWidgetUpdate() + return + } + + super.onReceive(context, intent) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + backgroundJobManager.startImmediatePhotoWidgetUpdate() + } + + 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) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt new file mode 100644 index 000000000000..d1134a794d31 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -0,0 +1,221 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.content.ContentResolver +import android.content.SharedPreferences +import android.graphics.Bitmap +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.GetMethod +import java.io.InputStream +import javax.inject.Inject + +/** + * Holds the result of picking a random image for the widget. + */ +data class PhotoWidgetImageResult( + val bitmap: Bitmap, + val latitude: Double?, + val longitude: Double?, + val modificationTimestamp: Long +) + +/** + * Repository that manages photo widget configurations and image retrieval. + * + * Responsibilities: + * - Save / delete / retrieve per-widget config in SharedPreferences + * - Query FileDataStorageManager for image files in the selected folder + * - Pick a random image and return a cached thumbnail Bitmap + */ +class PhotoWidgetRepository @Inject constructor( + private val preferences: SharedPreferences, + private val userAccountManager: UserAccountManager, + private val contentResolver: ContentResolver +) { + + companion object { + private const val TAG = "PhotoWidgetRepository" + private const val PREF_PREFIX = "photo_widget_" + private const val PREF_FOLDER_PATH = "${PREF_PREFIX}folder_path_" + private const val PREF_ACCOUNT_NAME = "${PREF_PREFIX}account_name_" + private const val PREF_INTERVAL_MINUTES = "${PREF_PREFIX}interval_minutes_" + private const val MAX_BITMAP_DIMENSION = 512 + private const val READ_TIMEOUT = 40000 + private const val CONNECTION_TIMEOUT = 5000 + } + + // --------------- Configuration persistence --------------- + + fun saveWidgetConfig(widgetId: Int, folderPath: String, accountName: String, intervalMinutes: Long = PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) { + preferences.edit() + .putString(PREF_FOLDER_PATH + widgetId, folderPath) + .putString(PREF_ACCOUNT_NAME + widgetId, accountName) + .putLong(PREF_INTERVAL_MINUTES + widgetId, intervalMinutes) + .apply() + } + + fun deleteWidgetConfig(widgetId: Int) { + preferences.edit() + .remove(PREF_FOLDER_PATH + widgetId) + .remove(PREF_ACCOUNT_NAME + widgetId) + .remove(PREF_INTERVAL_MINUTES + widgetId) + .apply() + } + + fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { + val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null + val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null + val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) + return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) + } + + // --------------- Image retrieval --------------- + + /** + * Returns a random image bitmap for the given widget, or `null` on any failure. + */ + fun getRandomImageBitmap(widgetId: Int): Bitmap? { + return getRandomImageResult(widgetId)?.bitmap + } + + /** + * Returns a random image result with bitmap and metadata, or `null` on failure. + * + * Shuffles all image files and tries each one until a thumbnail loads successfully. + * This ensures the widget falls back to cached/local images when the network + * connection is poor, rather than showing a placeholder. + */ + fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { + val config = getWidgetConfig(widgetId) ?: return null + val user = userAccountManager.getUser(config.accountName).orElse(null) ?: return null + + val storageManager = FileDataStorageManager(user, contentResolver) + val folder = storageManager.getFileByDecryptedRemotePath(config.folderPath) ?: return null + val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder) + + val imageFiles = allFiles.filter { isImageFile(it) }.shuffled() + + for (file in imageFiles) { + val bitmap = getThumbnailForFile(file, config.accountName) + if (bitmap != null) { + val geo = file.geoLocation + return PhotoWidgetImageResult( + bitmap = bitmap, + latitude = geo?.latitude, + longitude = geo?.longitude, + modificationTimestamp = file.modificationTimestamp + ) + } + } + return null + } + + @Suppress("MagicNumber") + private fun isImageFile(file: OCFile): Boolean { + val mimeType = file.mimeType ?: return false + return mimeType.startsWith("image/") + } + + /** + * Attempts to retrieve a cached thumbnail, or downloads it if missing. + */ + private fun getThumbnailForFile(file: OCFile, accountName: String): Bitmap? { + // 1. Try "resized" cache key + val imageKey = "r" + file.remoteId + var bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) + if (bitmap != null) return scaleBitmap(bitmap) + + // 2. Try "thumbnail" cache key + val thumbnailKey = "t" + file.remoteId + bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey) + if (bitmap != null) return scaleBitmap(bitmap) + + // 3. If missing, download from server + if (file.isDown) { + // If file is downloaded, generate from local storage + val dimension = ThumbnailsCacheManager.getThumbnailDimension() + bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, dimension, dimension) + if (bitmap != null) { + ThumbnailsCacheManager.addBitmapToCache(thumbnailKey, bitmap) + return scaleBitmap(bitmap) + } + } + + // 4. Download from server + return downloadThumbnail(file, thumbnailKey, accountName) + } + + private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { + val user = userAccountManager.getUser(accountName).orElse(null) ?: return null + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) + + val dimension = ThumbnailsCacheManager.getThumbnailDimension() + val uri = client.baseUri.toString() + "/index.php/core/preview?fileId=" + + file.localId + "&x=" + dimension + "&y=" + dimension + "&a=1&mode=cover&forceIcon=0" + + val loopKey = "download_thumb_${file.remoteId}" + Log_OC.d(TAG, "Downloading widget thumbnail: $uri") + + val getMethod = GetMethod(uri) + getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true") + getMethod.setRequestHeader(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE) + + try { + val status = client.executeMethod(getMethod, READ_TIMEOUT, CONNECTION_TIMEOUT) + if (status == HttpStatus.SC_OK) { + val inputStream: InputStream = getMethod.responseBodyAsStream + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) + if (bitmap != null) { + ThumbnailsCacheManager.addBitmapToCache(cacheKey, bitmap) + return scaleBitmap(bitmap) + } + } else { + client.exhaustResponse(getMethod.responseBodyAsStream) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error downloading widget thumbnail", e) + } finally { + getMethod.releaseConnection() + } + + return null + } + + 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) + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt new file mode 100644 index 000000000000..7a289b35da1e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -0,0 +1,150 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.location.Geocoder +import android.widget.RemoteViews +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.R +import com.owncloud.android.ui.activity.FileDisplayActivity +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Background worker that fetches a random photo and updates all photo widgets. + * + * Constructed by [com.nextcloud.client.jobs.BackgroundJobFactory]. + */ +class PhotoWidgetWorker( + private val context: Context, + params: WorkerParameters, + private val photoWidgetRepository: PhotoWidgetRepository, + private val userAccountManager: UserAccountManager +) : CoroutineWorker(context, params) { + + companion object { + const val TAG = "PhotoWidgetWorker" + private const val DATE_FORMAT = "dd MMM yyyy" + private const val NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000 + } + + override suspend fun doWork(): Result { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, PhotoWidgetProvider::class.java) + val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + + for (widgetId in widgetIds) { + updateWidget(appWidgetManager, widgetId) + } + + return Result.success() + } + + private fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { + val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) + + val imageResult = photoWidgetRepository.getRandomImageResult(widgetId) + if (imageResult != null) { + remoteViews.setImageViewBitmap(R.id.photo_widget_image, imageResult.bitmap) + + // Show the text container + remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.VISIBLE) + + // Location line (only if geolocation is available) + val locationText = resolveLocationName(imageResult.latitude, imageResult.longitude) + if (locationText != null) { + remoteViews.setTextViewText(R.id.photo_widget_location, locationText) + remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.VISIBLE) + } else { + remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) + } + + // Date line + val dateText = formatDate(imageResult.modificationTimestamp) + remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + } else { + remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) + remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) + } + + // Set click on photo to open the folder in FileDisplayActivity + val config = photoWidgetRepository.getWidgetConfig(widgetId) + val clickIntent = createOpenFolderIntent(config) + val openPendingIntent = PendingIntent.getActivity( + context, + widgetId, + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.photo_widget_image, openPendingIntent) + + // Set click on "next" button to refresh with a new random image + val nextIntent = Intent(context, PhotoWidgetProvider::class.java).apply { + action = PhotoWidgetProvider.ACTION_NEXT_IMAGE + } + val nextPendingIntent = PendingIntent.getBroadcast( + context, + widgetId + NEXT_BUTTON_REQUEST_CODE_OFFSET, + nextIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.photo_widget_next_button, nextPendingIntent) + + appWidgetManager.updateAppWidget(widgetId, remoteViews) + } + + /** + * Reverse-geocodes lat/long into a human-readable location name. + * Returns null if geocoding is unavailable or coordinates are missing. + */ + @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) + if (addresses != null && addresses.isNotEmpty()) { + val address = addresses[0] + // Build a concise location: "City, Country" or just "Country" + val city = address.locality ?: address.subAdminArea + val country = address.countryName + when { + city != null && country != null -> "$city, $country" + city != null -> city + country != null -> country + else -> null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + + private fun formatDate(timestampMillis: Long): String { + val sdf = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()) + return sdf.format(Date(timestampMillis)) + } + + 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) + return intent + } +} diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000000..0c3ba99559af --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml new file mode 100644 index 000000000000..a5f255f3b46b --- /dev/null +++ b/app/src/main/res/layout/widget_photo.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dee81fb3f3e0..6344f89a3d9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1354,6 +1354,19 @@ Reload Widgets are only available in %1$s 25 or later when the Dashboard app is enabled Not available + + + Displays a random photo from a Nextcloud folder + Photo from Nextcloud + Select photo folder + No images found in selected folder + Next image + Refresh interval + Every 5 minutes + Every 15 minutes + Every 30 minutes + Every 60 minutes + Manual only Icon for empty list No items Check back later or reload. diff --git a/app/src/main/res/xml/photo_widget_info.xml b/app/src/main/res/xml/photo_widget_info.xml new file mode 100644 index 000000000000..bdcd5306f1e2 --- /dev/null +++ b/app/src/main/res/xml/photo_widget_info.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt new file mode 100644 index 000000000000..0c1b3fed671e --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class PhotoWidgetRepositoryTest { + + @Mock + private lateinit var preferences: SharedPreferences + + @Mock + private lateinit var editor: SharedPreferences.Editor + + @Mock + private lateinit var userAccountManager: com.nextcloud.client.account.UserAccountManager + + @Mock + private lateinit var contentResolver: android.content.ContentResolver + + private lateinit var repository: PhotoWidgetRepository + + companion object { + private const val WIDGET_ID = 42 + private const val FOLDER_PATH = "/Photos/Vacation" + private const val ACCOUNT_NAME = "user@nextcloud.example.com" + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + whenever(preferences.edit()).thenReturn(editor) + whenever(editor.putString(anyString(), anyString())).thenReturn(editor) + whenever(editor.remove(anyString())).thenReturn(editor) + repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) + } + + @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() + } + + @Test + fun `getWidgetConfig returns config when both values are present`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(FOLDER_PATH) + whenever(preferences.getString(eq("photo_widget_account_name_$WIDGET_ID"), eq(null))) + .thenReturn(ACCOUNT_NAME) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNotNull(config) + assertEquals(WIDGET_ID, config!!.widgetId) + assertEquals(FOLDER_PATH, config.folderPath) + assertEquals(ACCOUNT_NAME, config.accountName) + } + + @Test + fun `getWidgetConfig returns null when folder path is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNull(config) + } + + @Test + fun `getWidgetConfig returns null when account name is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(FOLDER_PATH) + whenever(preferences.getString(eq("photo_widget_account_name_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNull(config) + } + + @Test + fun `deleteWidgetConfig removes both preference keys`() { + repository.deleteWidgetConfig(WIDGET_ID) + + verify(editor).remove(eq("photo_widget_folder_path_$WIDGET_ID")) + verify(editor).remove(eq("photo_widget_account_name_$WIDGET_ID")) + verify(editor).apply() + } + + @Test + fun `getRandomImageBitmap returns null when config is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val bitmap = repository.getRandomImageBitmap(WIDGET_ID) + + assertNull(bitmap) + } +} From 33ec730c4e1ffe1cfba1e7f7ae652bdba695598b Mon Sep 17 00:00:00 2001 From: szyxxx Date: Mon, 16 Feb 2026 18:03:24 +0700 Subject: [PATCH 02/20] feat: implement photo widget functionality including background job management and configuration. Signed-off-by: szyxxx --- .../client/jobs/BackgroundJobManagerImpl.kt | 2 +- .../widget/photo/PhotoWidgetConfigActivity.kt | 3 +- .../widget/photo/PhotoWidgetRepository.kt | 51 ++++++++++--------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 2ca8528836b4..c02075237744 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -854,7 +854,7 @@ internal class BackgroundJobManagerImpl( override fun startImmediatePhotoWidgetUpdate() { val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) .build() val request = oneTimeRequestBuilder( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index 2f79d7173b4b..25e6bc8ce4a7 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -131,7 +131,8 @@ class PhotoWidgetConfigActivity : Activity() { val accountName = userAccountManager.user.accountName // Save configuration (including interval) - photoWidgetRepository.saveWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + photoWidgetRepository.saveWidgetConfig(config) // Schedule periodic updates (or cancel if manual) backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index d1134a794d31..f34c3ea3fa44 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -53,18 +53,24 @@ class PhotoWidgetRepository @Inject constructor( private const val PREF_FOLDER_PATH = "${PREF_PREFIX}folder_path_" private const val PREF_ACCOUNT_NAME = "${PREF_PREFIX}account_name_" private const val PREF_INTERVAL_MINUTES = "${PREF_PREFIX}interval_minutes_" - private const val MAX_BITMAP_DIMENSION = 512 + private const val MAX_BITMAP_DIMENSION = 800 // Increased from 512 for better quality + private const val SERVER_REQUEST_DIMENSION = 2048 // Request high-res preview from server private const val READ_TIMEOUT = 40000 private const val CONNECTION_TIMEOUT = 5000 } - // --------------- Configuration persistence --------------- + fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { + val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null + val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null + val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) + return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) + } - fun saveWidgetConfig(widgetId: Int, folderPath: String, accountName: String, intervalMinutes: Long = PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) { + fun saveWidgetConfig(config: PhotoWidgetConfig) { preferences.edit() - .putString(PREF_FOLDER_PATH + widgetId, folderPath) - .putString(PREF_ACCOUNT_NAME + widgetId, accountName) - .putLong(PREF_INTERVAL_MINUTES + widgetId, intervalMinutes) + .putString(PREF_FOLDER_PATH + config.widgetId, config.folderPath) + .putString(PREF_ACCOUNT_NAME + config.widgetId, config.accountName) + .putLong(PREF_INTERVAL_MINUTES + config.widgetId, config.intervalMinutes) .apply() } @@ -76,13 +82,6 @@ class PhotoWidgetRepository @Inject constructor( .apply() } - fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { - val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null - val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null - val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) - return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) - } - // --------------- Image retrieval --------------- /** @@ -132,31 +131,33 @@ class PhotoWidgetRepository @Inject constructor( /** * Attempts to retrieve a cached thumbnail, or downloads it if missing. + * Tries "resized" (large) cache first for quality. */ private fun getThumbnailForFile(file: OCFile, accountName: String): Bitmap? { - // 1. Try "resized" cache key + // 1. Try "resized" cache key (Best Quality) val imageKey = "r" + file.remoteId var bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) if (bitmap != null) return scaleBitmap(bitmap) - // 2. Try "thumbnail" cache key + // 2. Try "thumbnail" cache key (Fallback) val thumbnailKey = "t" + file.remoteId bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey) if (bitmap != null) return scaleBitmap(bitmap) - // 3. If missing, download from server + // 3. If missing, generate from local file if (file.isDown) { - // If file is downloaded, generate from local storage - val dimension = ThumbnailsCacheManager.getThumbnailDimension() - bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, dimension, dimension) + // Generate high-quality local thumbnail + bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, SERVER_REQUEST_DIMENSION, SERVER_REQUEST_DIMENSION) if (bitmap != null) { - ThumbnailsCacheManager.addBitmapToCache(thumbnailKey, bitmap) + // Cache as "resized" for future high-quality use + val keyToCache = imageKey // Cache as 'r' (resized) + ThumbnailsCacheManager.addBitmapToCache(keyToCache, bitmap) return scaleBitmap(bitmap) } } - // 4. Download from server - return downloadThumbnail(file, thumbnailKey, accountName) + // 4. Download from server (High Res) + return downloadThumbnail(file, imageKey, accountName) } private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { @@ -164,12 +165,12 @@ class PhotoWidgetRepository @Inject constructor( val client = OwnCloudClientManagerFactory.getDefaultSingleton() .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) - val dimension = ThumbnailsCacheManager.getThumbnailDimension() + // Request high-res preview (2048px) + val dimension = SERVER_REQUEST_DIMENSION val uri = client.baseUri.toString() + "/index.php/core/preview?fileId=" + file.localId + "&x=" + dimension + "&y=" + dimension + "&a=1&mode=cover&forceIcon=0" - val loopKey = "download_thumb_${file.remoteId}" - Log_OC.d(TAG, "Downloading widget thumbnail: $uri") + Log_OC.d(TAG, "Downloading widget high-res preview: $uri") val getMethod = GetMethod(uri) getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true") From 14f7e7cd7d2ccaa021bd962d94f602ec47073fbf Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:42:57 +0700 Subject: [PATCH 03/20] feat: Implement a new photo widget with configuration, background updates, and image display. Signed-off-by: szyxxx --- .../client/jobs/BackgroundJobManagerImpl.kt | 2 - .../widget/photo/PhotoWidgetConfigActivity.kt | 7 +- .../widget/photo/PhotoWidgetProvider.kt | 16 +++ .../widget/photo/PhotoWidgetRepository.kt | 136 ++++++++++++++++-- .../client/widget/photo/PhotoWidgetWorker.kt | 34 ++++- .../res/drawable/bg_widget_button_circle.xml | 14 ++ app/src/main/res/layout/widget_photo.xml | 23 ++- .../widget/photo/PhotoWidgetRepositoryTest.kt | 5 +- 8 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 app/src/main/res/drawable/bg_widget_button_circle.xml diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index c02075237744..e3c3bfcf0f63 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -101,7 +101,6 @@ internal class BackgroundJobManagerImpl( 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 const val JOB_TEST = "test_job" @@ -834,7 +833,6 @@ internal class BackgroundJobManagerImpl( } val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = periodicRequestBuilder( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index 25e6bc8ce4a7..a00a3c19638c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -134,7 +134,12 @@ class PhotoWidgetConfigActivity : Activity() { val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) photoWidgetRepository.saveWidgetConfig(config) - // 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"). backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) // Trigger immediate widget update diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt index 6ba58f19efd2..a8472f5a26ca 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -36,6 +36,18 @@ class PhotoWidgetProvider : AppWidgetProvider() { // Handle "next image" button tap if (intent?.action == ACTION_NEXT_IMAGE) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = android.content.ComponentName(context, PhotoWidgetProvider::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + for (widgetId in appWidgetIds) { + // Show loading state immediately + val remoteViews = android.widget.RemoteViews(context.packageName, com.owncloud.android.R.layout.widget_photo) + remoteViews.setViewVisibility(com.owncloud.android.R.id.photo_widget_loading, android.view.View.VISIBLE) + remoteViews.setViewVisibility(com.owncloud.android.R.id.photo_widget_next_button, android.view.View.INVISIBLE) + appWidgetManager.partiallyUpdateAppWidget(widgetId, remoteViews) + } + backgroundJobManager.startImmediatePhotoWidgetUpdate() return } @@ -44,20 +56,24 @@ class PhotoWidgetProvider : AppWidgetProvider() { } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) backgroundJobManager.startImmediatePhotoWidgetUpdate() } override fun onEnabled(context: Context) { + AndroidInjection.inject(this, context) super.onEnabled(context) backgroundJobManager.schedulePeriodicPhotoWidgetUpdate() } override fun onDisabled(context: Context) { + AndroidInjection.inject(this, context) super.onDisabled(context) backgroundJobManager.cancelPeriodicPhotoWidgetUpdate() } override fun onDeleted(context: Context, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) super.onDeleted(context, appWidgetIds) for (widgetId in appWidgetIds) { photoWidgetRepository.deleteWidgetConfig(widgetId) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index f34c3ea3fa44..cc09d84b4ae9 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -100,27 +100,129 @@ class PhotoWidgetRepository @Inject constructor( */ fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { val config = getWidgetConfig(widgetId) ?: return null - val user = userAccountManager.getUser(config.accountName).orElse(null) ?: return null + val folderPath = config.folderPath + val accountName = config.accountName + val user = userAccountManager.getUser(accountName).orElse(null) ?: return null val storageManager = FileDataStorageManager(user, contentResolver) - val folder = storageManager.getFileByDecryptedRemotePath(config.folderPath) ?: return null + val folder = storageManager.getFileByDecryptedRemotePath(folderPath) ?: return null val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder) - val imageFiles = allFiles.filter { isImageFile(it) }.shuffled() + // IMPLEMENTATION OF "SMART MIX" STRATEGY + // 1. "On This Day": Photos from today's date in past years + val onThisDayFiles = allFiles.filter { isOnThisDay(it.modificationTimestamp) } - for (file in imageFiles) { - val bitmap = getThumbnailForFile(file, config.accountName) - if (bitmap != null) { - val geo = file.geoLocation - return PhotoWidgetImageResult( - bitmap = bitmap, - latitude = geo?.latitude, - longitude = geo?.longitude, - modificationTimestamp = file.modificationTimestamp - ) + // 2. "Recent": Top 20 newest photos + val recentFiles = allFiles.sortedByDescending { it.modificationTimestamp }.take(20) + + // 3. "Random": 10 random files from the rest to add variety + val usedIds = (onThisDayFiles + recentFiles).map { it.remoteId }.toSet() + val remainingFiles = allFiles.filter { !usedIds.contains(it.remoteId) } + val randomFiles = remainingFiles.shuffled().take(10) + + // Combine all candidates + val candidatePool = (onThisDayFiles + recentFiles + randomFiles).filter { isImageFile(it) } + + if (candidatePool.isEmpty()) { + return null + } + + // Prioritize images that are already downloaded or cached to avoid network timeouts + val (offlineFiles, onlineFiles) = candidatePool.partition { file -> + file.isDown || ThumbnailsCacheManager.containsBitmap(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) + } + + // 80% chance to pick from offline files if available, otherwise fallback to online + // This keeps the "randomness" but strongly biases towards instant loading + var candidateFile = if (offlineFiles.isNotEmpty() && (percentage(80) || onlineFiles.isEmpty())) { + offlineFiles.random() + } else if (onlineFiles.isNotEmpty()) { + onlineFiles.random() + } else { + candidatePool.random() + } + + var bitmap = getThumbnailForFile(candidateFile, accountName) + + // FAILSAFE: If the selected candidate failed (e.g. download error or missing file), + // and we have offline files available, try one of them instead of showing nothing. + if (bitmap == null && offlineFiles.isNotEmpty()) { + Log_OC.d(TAG, "Failed to load candidate image (isDown=${candidateFile.isDown}), trying fallback from ${offlineFiles.size} offline files") + // Try up to 3 random offline files to find a working one + for (i in 0 until 3) { + val fallback = offlineFiles.random() + bitmap = getThumbnailForFile(fallback, accountName) + if (bitmap != null) { + candidateFile = fallback + break + } } } - return null + + if (bitmap == null) { + Log_OC.e(TAG, "Failed to load any widget image") + return null + } + + // Update cache history and cleanup old entries + manageWidgetCache(widgetId, candidateFile, allFiles) + + val geo = candidateFile.geoLocation + return PhotoWidgetImageResult( + bitmap = bitmap, + latitude = geo?.latitude, + longitude = geo?.longitude, + modificationTimestamp = candidateFile.modificationTimestamp + ) + } + + private fun isOnThisDay(timestamp: Long): Boolean { + val calendar = java.util.Calendar.getInstance() + val todayMonth = calendar.get(java.util.Calendar.MONTH) + val todayDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + calendar.timeInMillis = timestamp + val fileMonth = calendar.get(java.util.Calendar.MONTH) + val fileDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + return (todayMonth == fileMonth && todayDay == fileDay) + } + + private fun manageWidgetCache(widgetId: Int, newFile: OCFile, allFiles: List) { + val prefKey = "${PREF_PREFIX}history_$widgetId" + val historyString = preferences.getString(prefKey, "") ?: "" + val history = if (historyString.isNotEmpty()) { + historyString.split(",").toMutableList() + } else { + mutableListOf() + } + + // Add new file ID if not present (move to end if present) + val newId = newFile.remoteId + if (history.contains(newId)) { + history.remove(newId) + } + history.add(newId) + + // Enforce limit of 10 + while (history.size > 10) { + val oldId = history.removeAt(0) + // Find the file to remove it from cache + val fileToRemove = allFiles.find { it.remoteId == oldId } + if (fileToRemove != null) { + Log_OC.d(TAG, "Evicting old widget image from cache: ${fileToRemove.fileName}") + ThumbnailsCacheManager.removeFromCache(fileToRemove) + } else { + Log_OC.d(TAG, "Could not find file object for eviction: $oldId") + } + } + + // Save updated history + preferences.edit().putString(prefKey, history.joinToString(",")).apply() + } + + private fun percentage(chance: Int): Boolean { + return (Math.random() * 100).toInt() < chance } @Suppress("MagicNumber") @@ -217,6 +319,10 @@ class PhotoWidgetRepository @Inject constructor( newWidth = (MAX_BITMAP_DIMENSION * ratio).toInt() } - return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + val scaledBitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + if (scaledBitmap != bitmap) { + bitmap.recycle() + } + return scaledBitmap } } diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 7a289b35da1e..620610ee1d1c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -17,10 +17,13 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Background worker that fetches a random photo and updates all photo widgets. @@ -52,7 +55,7 @@ class PhotoWidgetWorker( return Result.success() } - private fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { + private suspend fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) val imageResult = photoWidgetRepository.getRandomImageResult(widgetId) @@ -100,6 +103,9 @@ class PhotoWidgetWorker( nextIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Hide loading and show next button + remoteViews.setViewVisibility(R.id.photo_widget_loading, android.view.View.GONE) + remoteViews.setViewVisibility(R.id.photo_widget_next_button, android.view.View.VISIBLE) remoteViews.setOnClickPendingIntent(R.id.photo_widget_next_button, nextPendingIntent) appWidgetManager.updateAppWidget(widgetId, remoteViews) @@ -110,12 +116,21 @@ class PhotoWidgetWorker( * Returns null if geocoding is unavailable or coordinates are missing. */ @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 + private suspend fun resolveLocationName(latitude: Double?, longitude: Double?): String? = withContext(Dispatchers.IO) { + if (latitude == null || longitude == null) { + Log_OC.d(TAG, "Location resolution skipped: latitude=$latitude, longitude=$longitude") + return@withContext null + } + if (latitude == 0.0 && longitude == 0.0) { + Log_OC.d(TAG, "Location resolution skipped: coordinates are 0.0, 0.0") + return@withContext null + } - return try { - if (!Geocoder.isPresent()) return null + try { + if (!Geocoder.isPresent()) { + Log_OC.e(TAG, "Location resolution failed: Geocoder not present") + return@withContext null + } val geocoder = Geocoder(context, Locale.getDefault()) val addresses = geocoder.getFromLocation(latitude, longitude, 1) if (addresses != null && addresses.isNotEmpty()) { @@ -123,6 +138,7 @@ class PhotoWidgetWorker( // Build a concise location: "City, Country" or just "Country" val city = address.locality ?: address.subAdminArea val country = address.countryName + Log_OC.d(TAG, "Location resolved: city=$city, country=$country") when { city != null && country != null -> "$city, $country" city != null -> city @@ -130,9 +146,11 @@ class PhotoWidgetWorker( else -> null } } else { + Log_OC.d(TAG, "Location resolution: No address found for $latitude, $longitude") null } } catch (e: Exception) { + Log_OC.e(TAG, "Location resolution failed", e) null } } @@ -145,6 +163,10 @@ class PhotoWidgetWorker( 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) + if (config != null) { + intent.putExtra("folderPath", config.folderPath) + intent.putExtra("accountName", config.accountName) + } return intent } } diff --git a/app/src/main/res/drawable/bg_widget_button_circle.xml b/app/src/main/res/drawable/bg_widget_button_circle.xml new file mode 100644 index 000000000000..18163d9d70f7 --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_button_circle.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml index a5f255f3b46b..0754ac6656ff 100644 --- a/app/src/main/res/layout/widget_photo.xml +++ b/app/src/main/res/layout/widget_photo.xml @@ -66,17 +66,30 @@ - + + android:background="@drawable/bg_widget_button_circle" + android:tint="@android:color/white" + android:src="@drawable/ic_action_refresh" /> + + + diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt index 0c1b3fed671e..204679be62fa 100644 --- a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -50,12 +50,15 @@ class PhotoWidgetRepositoryTest { repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) } + @Test @Test fun `saveWidgetConfig stores folder path and account name`() { - repository.saveWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME) + val config = PhotoWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME, 15L) + repository.saveWidgetConfig(config) 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).putLong(eq("photo_widget_interval_minutes_$WIDGET_ID"), eq(15L)) verify(editor).apply() } From c21280263394fa0cf8d2480b4115b434136d5e63 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:50:36 +0700 Subject: [PATCH 04/20] fix: address PR review and Codacy static analysis issues Signed-off-by: szyxxx --- .../client/widget/photo/PhotoWidgetConfigActivity.kt | 1 + .../com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt | 1 + .../nextcloud/client/widget/photo/PhotoWidgetRepository.kt | 4 +++- .../com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt | 4 +++- .../client/widget/photo/PhotoWidgetRepositoryTest.kt | 4 ++-- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index a00a3c19638c..a8ca93b80449 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -26,6 +26,7 @@ import javax.inject.Inject * Opens [FolderPickerActivity] for folder selection, then shows an interval * picker dialog, saves the config, and triggers an immediate widget update. */ +@Suppress("TooManyFunctions") class PhotoWidgetConfigActivity : Activity() { companion object { diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt index a8472f5a26ca..a726f2f7518c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -19,6 +19,7 @@ import javax.inject.Inject * * Delegates heavy work to [PhotoWidgetWorker] via [BackgroundJobManager]. */ +@Suppress("TooManyFunctions") class PhotoWidgetProvider : AppWidgetProvider() { companion object { diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index cc09d84b4ae9..0e898cb17017 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -41,6 +41,7 @@ data class PhotoWidgetImageResult( * - Query FileDataStorageManager for image files in the selected folder * - Pick a random image and return a cached thumbnail Bitmap */ +@Suppress("MagicNumber", "TooManyFunctions") class PhotoWidgetRepository @Inject constructor( private val preferences: SharedPreferences, private val userAccountManager: UserAccountManager, @@ -98,6 +99,7 @@ class PhotoWidgetRepository @Inject constructor( * This ensures the widget falls back to cached/local images when the network * connection is poor, rather than showing a placeholder. */ + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { val config = getWidgetConfig(widgetId) ?: return null val folderPath = config.folderPath @@ -225,7 +227,6 @@ class PhotoWidgetRepository @Inject constructor( return (Math.random() * 100).toInt() < chance } - @Suppress("MagicNumber") private fun isImageFile(file: OCFile): Boolean { val mimeType = file.mimeType ?: return false return mimeType.startsWith("image/") @@ -262,6 +263,7 @@ class PhotoWidgetRepository @Inject constructor( return downloadThumbnail(file, imageKey, accountName) } + @Suppress("TooGenericExceptionCaught") private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { val user = userAccountManager.getUser(accountName).orElse(null) ?: return null val client = OwnCloudClientManagerFactory.getDefaultSingleton() diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 620610ee1d1c..6ffe9dd6cf9b 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext * * Constructed by [com.nextcloud.client.jobs.BackgroundJobFactory]. */ +@Suppress("TooManyFunctions") class PhotoWidgetWorker( private val context: Context, params: WorkerParameters, @@ -55,6 +56,7 @@ class PhotoWidgetWorker( return Result.success() } + @Suppress("LongMethod") private suspend fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) @@ -115,7 +117,7 @@ class PhotoWidgetWorker( * Reverse-geocodes lat/long into a human-readable location name. * Returns null if geocoding is unavailable or coordinates are missing. */ - @Suppress("DEPRECATION") + @Suppress("DEPRECATION", "TooGenericExceptionCaught", "ReturnCount", "CyclomaticComplexMethod") private suspend fun resolveLocationName(latitude: Double?, longitude: Double?): String? = withContext(Dispatchers.IO) { if (latitude == null || longitude == null) { Log_OC.d(TAG, "Location resolution skipped: latitude=$latitude, longitude=$longitude") diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt index 204679be62fa..ba210f28d3ae 100644 --- a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -46,13 +46,13 @@ class PhotoWidgetRepositoryTest { MockitoAnnotations.openMocks(this) whenever(preferences.edit()).thenReturn(editor) whenever(editor.putString(anyString(), anyString())).thenReturn(editor) + whenever(editor.putLong(anyString(), org.mockito.ArgumentMatchers.anyLong())).thenReturn(editor) whenever(editor.remove(anyString())).thenReturn(editor) repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) } @Test - @Test - fun `saveWidgetConfig stores folder path and account name`() { + fun `saveWidgetConfig stores folder path account name and interval`() { val config = PhotoWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME, 15L) repository.saveWidgetConfig(config) From 88323933382193e8132273ddec24807a9d2b3753 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:57:01 +0700 Subject: [PATCH 05/20] fix: add date validity check and use thread-safe DateTimeFormatter Signed-off-by: szyxxx --- .../client/widget/photo/PhotoWidgetWorker.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 6ffe9dd6cf9b..4bd730ede864 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -19,8 +19,9 @@ import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -76,9 +77,15 @@ class PhotoWidgetWorker( remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) } - // Date line - val dateText = formatDate(imageResult.modificationTimestamp) - remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + // Date line (only if timestamp is valid) + val timestamp = imageResult.modificationTimestamp + if (timestamp > 0L) { + val dateText = formatDate(timestamp) + remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.VISIBLE) + } else { + remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.GONE) + } } else { remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) @@ -158,8 +165,9 @@ class PhotoWidgetWorker( } private fun formatDate(timestampMillis: Long): String { - val sdf = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()) - return sdf.format(Date(timestampMillis)) + val formatter = DateTimeFormatter.ofPattern(DATE_FORMAT, Locale.getDefault()) + val instant = Instant.ofEpochMilli(timestampMillis) + return formatter.format(instant.atZone(ZoneId.systemDefault())) } private fun createOpenFolderIntent(config: PhotoWidgetConfig?): Intent { From 542ad660ac0f1455fe8ffefc640d0c5247ca7f7d Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 03:18:29 +0700 Subject: [PATCH 06/20] ci: add auto-sync upstream and auto-build release workflows Signed-off-by: szyxxx --- .github/workflows/build-release.yml | 120 ++++++++++++++++++++++++++++ .github/workflows/sync-upstream.yml | 73 +++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/sync-upstream.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 000000000000..699374cb6b45 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: 2026 Axel +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Build & Release APK" + +on: + push: + branches: [master] + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + +concurrency: + group: build-release + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 21 + + - name: Configure Gradle + run: | + mkdir -p "$HOME/.gradle" + cat >> gradle.properties < /tmp/release.keystore + APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) + $APKSIGNER sign \ + --ks /tmp/release.keystore \ + --ks-pass "pass:$KEYSTORE_PASSWORD" \ + --key-pass "pass:$KEY_PASSWORD" \ + --ks-key-alias release \ + app/build/outputs/apk/generic/release/*.apk + rm /tmp/release.keystore + + - name: Sign APK (debug fallback) + if: ${{ env.KEYSTORE_BASE64 == '' }} + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + # Use the default debug keystore if no release keystore is configured + APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) + DEBUG_KS="$HOME/.android/debug.keystore" + if [ ! -f "$DEBUG_KS" ]; then + keytool -genkey -v \ + -keystore "$DEBUG_KS" \ + -storepass android \ + -alias androiddebugkey \ + -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + # Find the unsigned APK + APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) + # zipalign first + ZIPALIGN=$(find "$ANDROID_HOME/build-tools" -name zipalign | sort | tail -n1) + $ZIPALIGN -f 4 "$APK" "${APK%.apk}-aligned.apk" + mv "${APK%.apk}-aligned.apk" "$APK" + $APKSIGNER sign \ + --ks "$DEBUG_KS" \ + --ks-pass pass:android \ + --key-pass pass:android \ + --ks-key-alias androiddebugkey \ + "$APK" + + - name: Get version info + id: version + run: | + APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) + VERSION=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts | head -1 || echo "dev") + DATE=$(date -u +"%Y-%m-%d") + echo "apk_path=$APK" >> "$GITHUB_OUTPUT" + echo "apk_name=$(basename $APK)" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "date=$DATE" >> "$GITHUB_OUTPUT" + + - name: Create/Update Release + uses: softprops/action-gh-release@v2 + with: + tag_name: latest + name: "Nextcloud Photos Widget — ${{ steps.version.outputs.date }}" + body: | + Auto-built from `master` on ${{ steps.version.outputs.date }}. + + **Version**: ${{ steps.version.outputs.version }} + **Commit**: ${{ github.sha }} + + Install via [Obtainium](https://github.com/ImranR98/Obtainium) for automatic updates. + draft: false + prerelease: false + make_latest: true + files: ${{ steps.version.outputs.apk_path }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000000..b64a5cd45a94 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Axel +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Sync Upstream" + +on: + schedule: + # Run daily at 04:00 UTC (11:00 WIB) + - cron: "0 4 * * *" + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + issues: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: git remote add upstream https://github.com/nextcloud/android.git || true + + - name: Fetch upstream + run: git fetch upstream master + + - name: Rebase on upstream + id: rebase + run: | + git rebase upstream/master + echo "success=true" >> "$GITHUB_OUTPUT" + continue-on-error: true + + - name: Handle rebase failure + if: steps.rebase.outputs.success != 'true' + run: | + git rebase --abort + echo "::error::Rebase failed due to conflicts. Manual resolution needed." + + - name: Push changes + if: steps.rebase.outputs.success == 'true' + run: git push --force-with-lease origin master + + - name: Create issue on failure + if: steps.rebase.outputs.success != 'true' + uses: actions/github-script@v7 + with: + script: | + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-conflict' + }); + if (existing.data.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⚠️ Upstream sync failed — rebase conflict', + body: 'The daily upstream sync failed due to merge conflicts.\n\nPlease resolve manually:\n```bash\ngit fetch upstream master\ngit rebase upstream/master\n# resolve conflicts\ngit push --force-with-lease origin master\n```', + labels: ['sync-conflict'] + }); + } From e95f327c332feef58c382244d08213d513f87f28 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 11:39:44 +0700 Subject: [PATCH 07/20] feat: implement photo widget worker and its layout to display random photos with location and refresh functionality. --- .../client/widget/photo/PhotoWidgetWorker.kt | 20 ------------------- app/src/main/res/layout/widget_photo.xml | 16 --------------- 2 files changed, 36 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 4bd730ede864..c09314197c27 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -19,9 +19,6 @@ import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -41,7 +38,6 @@ class PhotoWidgetWorker( companion object { const val TAG = "PhotoWidgetWorker" - private const val DATE_FORMAT = "dd MMM yyyy" private const val NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000 } @@ -76,16 +72,6 @@ class PhotoWidgetWorker( } else { remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) } - - // Date line (only if timestamp is valid) - val timestamp = imageResult.modificationTimestamp - if (timestamp > 0L) { - val dateText = formatDate(timestamp) - remoteViews.setTextViewText(R.id.photo_widget_date, dateText) - remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.VISIBLE) - } else { - remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.GONE) - } } else { remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) @@ -164,12 +150,6 @@ class PhotoWidgetWorker( } } - private fun formatDate(timestampMillis: Long): String { - val formatter = DateTimeFormatter.ofPattern(DATE_FORMAT, Locale.getDefault()) - val instant = Instant.ofEpochMilli(timestampMillis) - return formatter.format(instant.atZone(ZoneId.systemDefault())) - } - 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) diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml index 0754ac6656ff..e087f7032106 100644 --- a/app/src/main/res/layout/widget_photo.xml +++ b/app/src/main/res/layout/widget_photo.xml @@ -48,22 +48,6 @@ tools:text="Jakarta, Indonesia" tools:visibility="visible" /> - - From 77acabcf601b39fa9e13943dacaeb4fe265d5fac Mon Sep 17 00:00:00 2001 From: szyxxx Date: Mon, 16 Feb 2026 17:28:23 +0700 Subject: [PATCH 08/20] feat: implement a new photo widget to display Nextcloud photos. Signed-off-by: szyxxx --- app/src/main/AndroidManifest.xml | 20 ++ .../nextcloud/client/di/ComponentsModule.java | 8 + .../client/jobs/BackgroundJobFactory.kt | 14 +- .../client/jobs/BackgroundJobManager.kt | 5 + .../client/jobs/BackgroundJobManagerImpl.kt | 54 +++++ .../client/widget/photo/PhotoWidgetConfig.kt | 24 ++ .../widget/photo/PhotoWidgetConfigActivity.kt | 149 ++++++++++++ .../widget/photo/PhotoWidgetProvider.kt | 66 ++++++ .../widget/photo/PhotoWidgetRepository.kt | 221 ++++++++++++++++++ .../client/widget/photo/PhotoWidgetWorker.kt | 150 ++++++++++++ app/src/main/res/drawable/ic_skip_next.xml | 16 ++ app/src/main/res/layout/widget_photo.xml | 82 +++++++ app/src/main/res/values/strings.xml | 13 ++ app/src/main/res/xml/photo_widget_info.xml | 17 ++ .../widget/photo/PhotoWidgetRepositoryTest.kt | 117 ++++++++++ 15 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt create mode 100644 app/src/main/res/drawable/ic_skip_next.xml create mode 100644 app/src/main/res/layout/widget_photo.xml create mode 100644 app/src/main/res/xml/photo_widget_info.xml create mode 100644 app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20c10607b707..2b8b9772790d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -332,6 +332,26 @@ android:resource="@xml/dashboard_widget_info" /> + + + + + + + + + + + + + + , private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, - private val database: NextcloudDatabase + private val database: NextcloudDatabase, + private val photoWidgetRepository: Provider ) : WorkerFactory() { @SuppressLint("NewApi") @@ -104,6 +105,7 @@ class BackgroundJobFactory @Inject constructor( InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters) MetadataWorker::class -> createMetadataWorker(context, workerParameters) FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters) + com.nextcloud.client.widget.photo.PhotoWidgetWorker::class -> createPhotoWidgetWorker(context, workerParameters) else -> null // caller falls back to default factory } } @@ -299,4 +301,14 @@ class BackgroundJobFactory @Inject constructor( viewThemeUtils.get(), params ) + private fun createPhotoWidgetWorker( + context: Context, + params: WorkerParameters + ): com.nextcloud.client.widget.photo.PhotoWidgetWorker = + com.nextcloud.client.widget.photo.PhotoWidgetWorker( + context, + params, + photoWidgetRepository.get(), + accountManager + ) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 0dfef42aba27..6531da51e8b9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -171,4 +171,9 @@ interface BackgroundJobManager { fun startMetadataSyncJob(currentDirPath: String) fun downloadFolder(folder: OCFile, accountName: String) fun cancelFolderDownload() + + // Photo widget + fun schedulePeriodicPhotoWidgetUpdate(intervalMinutes: Long = 15L) + fun startImmediatePhotoWidgetUpdate() + fun cancelPeriodicPhotoWidgetUpdate() } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index c5f6ceb021d4..2ca8528836b4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -99,6 +99,9 @@ internal class BackgroundJobManagerImpl( const val JOB_DOWNLOAD_FOLDER = "download_folder" const val JOB_METADATA_SYNC = "metadata_sync" 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 const val JOB_TEST = "test_job" @@ -820,4 +823,55 @@ internal class BackgroundJobManagerImpl( override fun cancelFolderDownload() { workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER) } + + // --------------- Photo Widget --------------- + + 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 + ) + } + + override fun startImmediatePhotoWidgetUpdate() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = oneTimeRequestBuilder( + com.nextcloud.client.widget.photo.PhotoWidgetWorker::class, + JOB_IMMEDIATE_PHOTO_WIDGET + ) + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork( + JOB_IMMEDIATE_PHOTO_WIDGET, + ExistingWorkPolicy.REPLACE, + request + ) + } + + override fun cancelPeriodicPhotoWidgetUpdate() { + workManager.cancelJob(JOB_PERIODIC_PHOTO_WIDGET) + } } diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt new file mode 100644 index 000000000000..165630b00cb5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +/** + * Holds per-widget configuration for the Photo Widget. + * + * @param intervalMinutes Refresh interval in minutes. 0 means manual-only (no auto-refresh). + */ +data class PhotoWidgetConfig( + val widgetId: Int, + val folderPath: String, + val accountName: String, + val intervalMinutes: Long = DEFAULT_INTERVAL_MINUTES +) { + companion object { + const val DEFAULT_INTERVAL_MINUTES = 15L + val INTERVAL_OPTIONS = longArrayOf(5L, 15L, 30L, 60L, 0L) // 0 = manual + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt new file mode 100644 index 000000000000..2f79d7173b4b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.app.Activity +import android.app.AlertDialog +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Build +import android.os.Bundle +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FolderPickerActivity +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * Configuration activity launched when the user places a Photo Widget. + * + * Opens [FolderPickerActivity] for folder selection, then shows an interval + * picker dialog, saves the config, and triggers an immediate widget update. + */ +class PhotoWidgetConfigActivity : Activity() { + + companion object { + private const val REQUEST_FOLDER_PICKER = 1001 + } + + @Inject + lateinit var photoWidgetRepository: PhotoWidgetRepository + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var userAccountManager: UserAccountManager + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + + // Set result to CANCELED in case the user backs out + setResult(RESULT_CANCELED) + + // Extract the widget ID from the intent + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + // Launch FolderPickerActivity for folder selection + val folderPickerIntent = Intent(this, FolderPickerActivity::class.java).apply { + putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + } + @Suppress("DEPRECATION") + startActivityForResult(folderPickerIntent, REQUEST_FOLDER_PICKER) + } + + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQUEST_FOLDER_PICKER && resultCode == RESULT_OK && data != null) { + val folder: OCFile? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER, OCFile::class.java) + } else { + @Suppress("DEPRECATION") + data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER) + } + + if (folder != null) { + showIntervalPicker(folder) + } else { + finish() + } + } else { + finish() + } + } + + /** + * Shows a dialog for the user to pick a refresh interval. + * Presets: 5 / 15 / 30 / 60 minutes / Manual only. + */ + private fun showIntervalPicker(selectedFolder: OCFile) { + val labels = arrayOf( + getString(R.string.photo_widget_interval_5), + getString(R.string.photo_widget_interval_15), + getString(R.string.photo_widget_interval_30), + getString(R.string.photo_widget_interval_60), + getString(R.string.photo_widget_interval_manual) + ) + val values = PhotoWidgetConfig.INTERVAL_OPTIONS // [5, 15, 30, 60, 0] + + // Default selection: 15 minutes (index 1) + var selectedIndex = 1 + + AlertDialog.Builder(this) + .setTitle(getString(R.string.photo_widget_interval_title)) + .setSingleChoiceItems(labels, selectedIndex) { _, which -> + selectedIndex = which + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val intervalMinutes = values[selectedIndex] + finishConfiguration(selectedFolder, intervalMinutes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + finish() + } + .setOnCancelListener { + finish() + } + .show() + } + + private fun finishConfiguration(folder: OCFile, intervalMinutes: Long) { + val folderPath = folder.remotePath + val accountName = userAccountManager.user.accountName + + // Save configuration (including interval) + photoWidgetRepository.saveWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + + // Schedule periodic updates (or cancel if manual) + backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) + + // Trigger immediate widget update + backgroundJobManager.startImmediatePhotoWidgetUpdate() + + // Return success + val resultValue = Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + setResult(RESULT_OK, resultValue) + finish() + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt new file mode 100644 index 000000000000..6ba58f19efd2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import com.nextcloud.client.jobs.BackgroundJobManager +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * App widget provider for the Photo Widget. + * + * Delegates heavy work to [PhotoWidgetWorker] via [BackgroundJobManager]. + */ +class PhotoWidgetProvider : AppWidgetProvider() { + + companion object { + const val ACTION_NEXT_IMAGE = "com.nextcloud.client.widget.photo.ACTION_NEXT_IMAGE" + } + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var photoWidgetRepository: PhotoWidgetRepository + + override fun onReceive(context: Context, intent: Intent?) { + AndroidInjection.inject(this, context) + + // Handle "next image" button tap + if (intent?.action == ACTION_NEXT_IMAGE) { + backgroundJobManager.startImmediatePhotoWidgetUpdate() + return + } + + super.onReceive(context, intent) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + backgroundJobManager.startImmediatePhotoWidgetUpdate() + } + + 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) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt new file mode 100644 index 000000000000..d1134a794d31 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -0,0 +1,221 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.content.ContentResolver +import android.content.SharedPreferences +import android.graphics.Bitmap +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.GetMethod +import java.io.InputStream +import javax.inject.Inject + +/** + * Holds the result of picking a random image for the widget. + */ +data class PhotoWidgetImageResult( + val bitmap: Bitmap, + val latitude: Double?, + val longitude: Double?, + val modificationTimestamp: Long +) + +/** + * Repository that manages photo widget configurations and image retrieval. + * + * Responsibilities: + * - Save / delete / retrieve per-widget config in SharedPreferences + * - Query FileDataStorageManager for image files in the selected folder + * - Pick a random image and return a cached thumbnail Bitmap + */ +class PhotoWidgetRepository @Inject constructor( + private val preferences: SharedPreferences, + private val userAccountManager: UserAccountManager, + private val contentResolver: ContentResolver +) { + + companion object { + private const val TAG = "PhotoWidgetRepository" + private const val PREF_PREFIX = "photo_widget_" + private const val PREF_FOLDER_PATH = "${PREF_PREFIX}folder_path_" + private const val PREF_ACCOUNT_NAME = "${PREF_PREFIX}account_name_" + private const val PREF_INTERVAL_MINUTES = "${PREF_PREFIX}interval_minutes_" + private const val MAX_BITMAP_DIMENSION = 512 + private const val READ_TIMEOUT = 40000 + private const val CONNECTION_TIMEOUT = 5000 + } + + // --------------- Configuration persistence --------------- + + fun saveWidgetConfig(widgetId: Int, folderPath: String, accountName: String, intervalMinutes: Long = PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) { + preferences.edit() + .putString(PREF_FOLDER_PATH + widgetId, folderPath) + .putString(PREF_ACCOUNT_NAME + widgetId, accountName) + .putLong(PREF_INTERVAL_MINUTES + widgetId, intervalMinutes) + .apply() + } + + fun deleteWidgetConfig(widgetId: Int) { + preferences.edit() + .remove(PREF_FOLDER_PATH + widgetId) + .remove(PREF_ACCOUNT_NAME + widgetId) + .remove(PREF_INTERVAL_MINUTES + widgetId) + .apply() + } + + fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { + val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null + val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null + val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) + return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) + } + + // --------------- Image retrieval --------------- + + /** + * Returns a random image bitmap for the given widget, or `null` on any failure. + */ + fun getRandomImageBitmap(widgetId: Int): Bitmap? { + return getRandomImageResult(widgetId)?.bitmap + } + + /** + * Returns a random image result with bitmap and metadata, or `null` on failure. + * + * Shuffles all image files and tries each one until a thumbnail loads successfully. + * This ensures the widget falls back to cached/local images when the network + * connection is poor, rather than showing a placeholder. + */ + fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { + val config = getWidgetConfig(widgetId) ?: return null + val user = userAccountManager.getUser(config.accountName).orElse(null) ?: return null + + val storageManager = FileDataStorageManager(user, contentResolver) + val folder = storageManager.getFileByDecryptedRemotePath(config.folderPath) ?: return null + val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder) + + val imageFiles = allFiles.filter { isImageFile(it) }.shuffled() + + for (file in imageFiles) { + val bitmap = getThumbnailForFile(file, config.accountName) + if (bitmap != null) { + val geo = file.geoLocation + return PhotoWidgetImageResult( + bitmap = bitmap, + latitude = geo?.latitude, + longitude = geo?.longitude, + modificationTimestamp = file.modificationTimestamp + ) + } + } + return null + } + + @Suppress("MagicNumber") + private fun isImageFile(file: OCFile): Boolean { + val mimeType = file.mimeType ?: return false + return mimeType.startsWith("image/") + } + + /** + * Attempts to retrieve a cached thumbnail, or downloads it if missing. + */ + private fun getThumbnailForFile(file: OCFile, accountName: String): Bitmap? { + // 1. Try "resized" cache key + val imageKey = "r" + file.remoteId + var bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) + if (bitmap != null) return scaleBitmap(bitmap) + + // 2. Try "thumbnail" cache key + val thumbnailKey = "t" + file.remoteId + bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey) + if (bitmap != null) return scaleBitmap(bitmap) + + // 3. If missing, download from server + if (file.isDown) { + // If file is downloaded, generate from local storage + val dimension = ThumbnailsCacheManager.getThumbnailDimension() + bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, dimension, dimension) + if (bitmap != null) { + ThumbnailsCacheManager.addBitmapToCache(thumbnailKey, bitmap) + return scaleBitmap(bitmap) + } + } + + // 4. Download from server + return downloadThumbnail(file, thumbnailKey, accountName) + } + + private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { + val user = userAccountManager.getUser(accountName).orElse(null) ?: return null + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) + + val dimension = ThumbnailsCacheManager.getThumbnailDimension() + val uri = client.baseUri.toString() + "/index.php/core/preview?fileId=" + + file.localId + "&x=" + dimension + "&y=" + dimension + "&a=1&mode=cover&forceIcon=0" + + val loopKey = "download_thumb_${file.remoteId}" + Log_OC.d(TAG, "Downloading widget thumbnail: $uri") + + val getMethod = GetMethod(uri) + getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true") + getMethod.setRequestHeader(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE) + + try { + val status = client.executeMethod(getMethod, READ_TIMEOUT, CONNECTION_TIMEOUT) + if (status == HttpStatus.SC_OK) { + val inputStream: InputStream = getMethod.responseBodyAsStream + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) + if (bitmap != null) { + ThumbnailsCacheManager.addBitmapToCache(cacheKey, bitmap) + return scaleBitmap(bitmap) + } + } else { + client.exhaustResponse(getMethod.responseBodyAsStream) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error downloading widget thumbnail", e) + } finally { + getMethod.releaseConnection() + } + + return null + } + + 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) + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt new file mode 100644 index 000000000000..7a289b35da1e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -0,0 +1,150 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.location.Geocoder +import android.widget.RemoteViews +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.R +import com.owncloud.android.ui.activity.FileDisplayActivity +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Background worker that fetches a random photo and updates all photo widgets. + * + * Constructed by [com.nextcloud.client.jobs.BackgroundJobFactory]. + */ +class PhotoWidgetWorker( + private val context: Context, + params: WorkerParameters, + private val photoWidgetRepository: PhotoWidgetRepository, + private val userAccountManager: UserAccountManager +) : CoroutineWorker(context, params) { + + companion object { + const val TAG = "PhotoWidgetWorker" + private const val DATE_FORMAT = "dd MMM yyyy" + private const val NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000 + } + + override suspend fun doWork(): Result { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, PhotoWidgetProvider::class.java) + val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + + for (widgetId in widgetIds) { + updateWidget(appWidgetManager, widgetId) + } + + return Result.success() + } + + private fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { + val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) + + val imageResult = photoWidgetRepository.getRandomImageResult(widgetId) + if (imageResult != null) { + remoteViews.setImageViewBitmap(R.id.photo_widget_image, imageResult.bitmap) + + // Show the text container + remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.VISIBLE) + + // Location line (only if geolocation is available) + val locationText = resolveLocationName(imageResult.latitude, imageResult.longitude) + if (locationText != null) { + remoteViews.setTextViewText(R.id.photo_widget_location, locationText) + remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.VISIBLE) + } else { + remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) + } + + // Date line + val dateText = formatDate(imageResult.modificationTimestamp) + remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + } else { + remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) + remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) + } + + // Set click on photo to open the folder in FileDisplayActivity + val config = photoWidgetRepository.getWidgetConfig(widgetId) + val clickIntent = createOpenFolderIntent(config) + val openPendingIntent = PendingIntent.getActivity( + context, + widgetId, + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.photo_widget_image, openPendingIntent) + + // Set click on "next" button to refresh with a new random image + val nextIntent = Intent(context, PhotoWidgetProvider::class.java).apply { + action = PhotoWidgetProvider.ACTION_NEXT_IMAGE + } + val nextPendingIntent = PendingIntent.getBroadcast( + context, + widgetId + NEXT_BUTTON_REQUEST_CODE_OFFSET, + nextIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.photo_widget_next_button, nextPendingIntent) + + appWidgetManager.updateAppWidget(widgetId, remoteViews) + } + + /** + * Reverse-geocodes lat/long into a human-readable location name. + * Returns null if geocoding is unavailable or coordinates are missing. + */ + @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) + if (addresses != null && addresses.isNotEmpty()) { + val address = addresses[0] + // Build a concise location: "City, Country" or just "Country" + val city = address.locality ?: address.subAdminArea + val country = address.countryName + when { + city != null && country != null -> "$city, $country" + city != null -> city + country != null -> country + else -> null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + + private fun formatDate(timestampMillis: Long): String { + val sdf = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()) + return sdf.format(Date(timestampMillis)) + } + + 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) + return intent + } +} diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000000..0c3ba99559af --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml new file mode 100644 index 000000000000..a5f255f3b46b --- /dev/null +++ b/app/src/main/res/layout/widget_photo.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dee81fb3f3e0..6344f89a3d9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1354,6 +1354,19 @@ Reload Widgets are only available in %1$s 25 or later when the Dashboard app is enabled Not available + + + Displays a random photo from a Nextcloud folder + Photo from Nextcloud + Select photo folder + No images found in selected folder + Next image + Refresh interval + Every 5 minutes + Every 15 minutes + Every 30 minutes + Every 60 minutes + Manual only Icon for empty list No items Check back later or reload. diff --git a/app/src/main/res/xml/photo_widget_info.xml b/app/src/main/res/xml/photo_widget_info.xml new file mode 100644 index 000000000000..bdcd5306f1e2 --- /dev/null +++ b/app/src/main/res/xml/photo_widget_info.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt new file mode 100644 index 000000000000..0c1b3fed671e --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class PhotoWidgetRepositoryTest { + + @Mock + private lateinit var preferences: SharedPreferences + + @Mock + private lateinit var editor: SharedPreferences.Editor + + @Mock + private lateinit var userAccountManager: com.nextcloud.client.account.UserAccountManager + + @Mock + private lateinit var contentResolver: android.content.ContentResolver + + private lateinit var repository: PhotoWidgetRepository + + companion object { + private const val WIDGET_ID = 42 + private const val FOLDER_PATH = "/Photos/Vacation" + private const val ACCOUNT_NAME = "user@nextcloud.example.com" + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + whenever(preferences.edit()).thenReturn(editor) + whenever(editor.putString(anyString(), anyString())).thenReturn(editor) + whenever(editor.remove(anyString())).thenReturn(editor) + repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) + } + + @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() + } + + @Test + fun `getWidgetConfig returns config when both values are present`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(FOLDER_PATH) + whenever(preferences.getString(eq("photo_widget_account_name_$WIDGET_ID"), eq(null))) + .thenReturn(ACCOUNT_NAME) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNotNull(config) + assertEquals(WIDGET_ID, config!!.widgetId) + assertEquals(FOLDER_PATH, config.folderPath) + assertEquals(ACCOUNT_NAME, config.accountName) + } + + @Test + fun `getWidgetConfig returns null when folder path is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNull(config) + } + + @Test + fun `getWidgetConfig returns null when account name is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(FOLDER_PATH) + whenever(preferences.getString(eq("photo_widget_account_name_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val config = repository.getWidgetConfig(WIDGET_ID) + + assertNull(config) + } + + @Test + fun `deleteWidgetConfig removes both preference keys`() { + repository.deleteWidgetConfig(WIDGET_ID) + + verify(editor).remove(eq("photo_widget_folder_path_$WIDGET_ID")) + verify(editor).remove(eq("photo_widget_account_name_$WIDGET_ID")) + verify(editor).apply() + } + + @Test + fun `getRandomImageBitmap returns null when config is missing`() { + whenever(preferences.getString(eq("photo_widget_folder_path_$WIDGET_ID"), eq(null))) + .thenReturn(null) + + val bitmap = repository.getRandomImageBitmap(WIDGET_ID) + + assertNull(bitmap) + } +} From c272673cc09ef8c2b20e45480340f6d351106bc2 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Mon, 16 Feb 2026 18:03:24 +0700 Subject: [PATCH 09/20] feat: implement photo widget functionality including background job management and configuration. Signed-off-by: szyxxx --- .../client/jobs/BackgroundJobManagerImpl.kt | 2 +- .../widget/photo/PhotoWidgetConfigActivity.kt | 3 +- .../widget/photo/PhotoWidgetRepository.kt | 51 ++++++++++--------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 2ca8528836b4..c02075237744 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -854,7 +854,7 @@ internal class BackgroundJobManagerImpl( override fun startImmediatePhotoWidgetUpdate() { val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) .build() val request = oneTimeRequestBuilder( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index 2f79d7173b4b..25e6bc8ce4a7 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -131,7 +131,8 @@ class PhotoWidgetConfigActivity : Activity() { val accountName = userAccountManager.user.accountName // Save configuration (including interval) - photoWidgetRepository.saveWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) + photoWidgetRepository.saveWidgetConfig(config) // Schedule periodic updates (or cancel if manual) backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index d1134a794d31..f34c3ea3fa44 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -53,18 +53,24 @@ class PhotoWidgetRepository @Inject constructor( private const val PREF_FOLDER_PATH = "${PREF_PREFIX}folder_path_" private const val PREF_ACCOUNT_NAME = "${PREF_PREFIX}account_name_" private const val PREF_INTERVAL_MINUTES = "${PREF_PREFIX}interval_minutes_" - private const val MAX_BITMAP_DIMENSION = 512 + private const val MAX_BITMAP_DIMENSION = 800 // Increased from 512 for better quality + private const val SERVER_REQUEST_DIMENSION = 2048 // Request high-res preview from server private const val READ_TIMEOUT = 40000 private const val CONNECTION_TIMEOUT = 5000 } - // --------------- Configuration persistence --------------- + fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { + val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null + val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null + val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) + return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) + } - fun saveWidgetConfig(widgetId: Int, folderPath: String, accountName: String, intervalMinutes: Long = PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) { + fun saveWidgetConfig(config: PhotoWidgetConfig) { preferences.edit() - .putString(PREF_FOLDER_PATH + widgetId, folderPath) - .putString(PREF_ACCOUNT_NAME + widgetId, accountName) - .putLong(PREF_INTERVAL_MINUTES + widgetId, intervalMinutes) + .putString(PREF_FOLDER_PATH + config.widgetId, config.folderPath) + .putString(PREF_ACCOUNT_NAME + config.widgetId, config.accountName) + .putLong(PREF_INTERVAL_MINUTES + config.widgetId, config.intervalMinutes) .apply() } @@ -76,13 +82,6 @@ class PhotoWidgetRepository @Inject constructor( .apply() } - fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { - val folderPath = preferences.getString(PREF_FOLDER_PATH + widgetId, null) ?: return null - val accountName = preferences.getString(PREF_ACCOUNT_NAME + widgetId, null) ?: return null - val interval = preferences.getLong(PREF_INTERVAL_MINUTES + widgetId, PhotoWidgetConfig.DEFAULT_INTERVAL_MINUTES) - return PhotoWidgetConfig(widgetId, folderPath, accountName, interval) - } - // --------------- Image retrieval --------------- /** @@ -132,31 +131,33 @@ class PhotoWidgetRepository @Inject constructor( /** * Attempts to retrieve a cached thumbnail, or downloads it if missing. + * Tries "resized" (large) cache first for quality. */ private fun getThumbnailForFile(file: OCFile, accountName: String): Bitmap? { - // 1. Try "resized" cache key + // 1. Try "resized" cache key (Best Quality) val imageKey = "r" + file.remoteId var bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) if (bitmap != null) return scaleBitmap(bitmap) - // 2. Try "thumbnail" cache key + // 2. Try "thumbnail" cache key (Fallback) val thumbnailKey = "t" + file.remoteId bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey) if (bitmap != null) return scaleBitmap(bitmap) - // 3. If missing, download from server + // 3. If missing, generate from local file if (file.isDown) { - // If file is downloaded, generate from local storage - val dimension = ThumbnailsCacheManager.getThumbnailDimension() - bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, dimension, dimension) + // Generate high-quality local thumbnail + bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, SERVER_REQUEST_DIMENSION, SERVER_REQUEST_DIMENSION) if (bitmap != null) { - ThumbnailsCacheManager.addBitmapToCache(thumbnailKey, bitmap) + // Cache as "resized" for future high-quality use + val keyToCache = imageKey // Cache as 'r' (resized) + ThumbnailsCacheManager.addBitmapToCache(keyToCache, bitmap) return scaleBitmap(bitmap) } } - // 4. Download from server - return downloadThumbnail(file, thumbnailKey, accountName) + // 4. Download from server (High Res) + return downloadThumbnail(file, imageKey, accountName) } private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { @@ -164,12 +165,12 @@ class PhotoWidgetRepository @Inject constructor( val client = OwnCloudClientManagerFactory.getDefaultSingleton() .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) - val dimension = ThumbnailsCacheManager.getThumbnailDimension() + // Request high-res preview (2048px) + val dimension = SERVER_REQUEST_DIMENSION val uri = client.baseUri.toString() + "/index.php/core/preview?fileId=" + file.localId + "&x=" + dimension + "&y=" + dimension + "&a=1&mode=cover&forceIcon=0" - val loopKey = "download_thumb_${file.remoteId}" - Log_OC.d(TAG, "Downloading widget thumbnail: $uri") + Log_OC.d(TAG, "Downloading widget high-res preview: $uri") val getMethod = GetMethod(uri) getMethod.setRequestHeader("Cookie", "nc_sameSiteCookielax=true;nc_sameSiteCookiestrict=true") From 1ff376015e5dcb5e22f8a587119a9730b0818bc0 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:42:57 +0700 Subject: [PATCH 10/20] feat: Implement a new photo widget with configuration, background updates, and image display. Signed-off-by: szyxxx --- .../client/jobs/BackgroundJobManagerImpl.kt | 2 - .../widget/photo/PhotoWidgetConfigActivity.kt | 7 +- .../widget/photo/PhotoWidgetProvider.kt | 16 +++ .../widget/photo/PhotoWidgetRepository.kt | 136 ++++++++++++++++-- .../client/widget/photo/PhotoWidgetWorker.kt | 34 ++++- .../res/drawable/bg_widget_button_circle.xml | 14 ++ app/src/main/res/layout/widget_photo.xml | 23 ++- .../widget/photo/PhotoWidgetRepositoryTest.kt | 5 +- 8 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 app/src/main/res/drawable/bg_widget_button_circle.xml diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index c02075237744..e3c3bfcf0f63 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -101,7 +101,6 @@ internal class BackgroundJobManagerImpl( 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 const val JOB_TEST = "test_job" @@ -834,7 +833,6 @@ internal class BackgroundJobManagerImpl( } val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = periodicRequestBuilder( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index 25e6bc8ce4a7..a00a3c19638c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -134,7 +134,12 @@ class PhotoWidgetConfigActivity : Activity() { val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) photoWidgetRepository.saveWidgetConfig(config) - // 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"). backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) // Trigger immediate widget update diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt index 6ba58f19efd2..a8472f5a26ca 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -36,6 +36,18 @@ class PhotoWidgetProvider : AppWidgetProvider() { // Handle "next image" button tap if (intent?.action == ACTION_NEXT_IMAGE) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = android.content.ComponentName(context, PhotoWidgetProvider::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + for (widgetId in appWidgetIds) { + // Show loading state immediately + val remoteViews = android.widget.RemoteViews(context.packageName, com.owncloud.android.R.layout.widget_photo) + remoteViews.setViewVisibility(com.owncloud.android.R.id.photo_widget_loading, android.view.View.VISIBLE) + remoteViews.setViewVisibility(com.owncloud.android.R.id.photo_widget_next_button, android.view.View.INVISIBLE) + appWidgetManager.partiallyUpdateAppWidget(widgetId, remoteViews) + } + backgroundJobManager.startImmediatePhotoWidgetUpdate() return } @@ -44,20 +56,24 @@ class PhotoWidgetProvider : AppWidgetProvider() { } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) backgroundJobManager.startImmediatePhotoWidgetUpdate() } override fun onEnabled(context: Context) { + AndroidInjection.inject(this, context) super.onEnabled(context) backgroundJobManager.schedulePeriodicPhotoWidgetUpdate() } override fun onDisabled(context: Context) { + AndroidInjection.inject(this, context) super.onDisabled(context) backgroundJobManager.cancelPeriodicPhotoWidgetUpdate() } override fun onDeleted(context: Context, appWidgetIds: IntArray) { + AndroidInjection.inject(this, context) super.onDeleted(context, appWidgetIds) for (widgetId in appWidgetIds) { photoWidgetRepository.deleteWidgetConfig(widgetId) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index f34c3ea3fa44..cc09d84b4ae9 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -100,27 +100,129 @@ class PhotoWidgetRepository @Inject constructor( */ fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { val config = getWidgetConfig(widgetId) ?: return null - val user = userAccountManager.getUser(config.accountName).orElse(null) ?: return null + val folderPath = config.folderPath + val accountName = config.accountName + val user = userAccountManager.getUser(accountName).orElse(null) ?: return null val storageManager = FileDataStorageManager(user, contentResolver) - val folder = storageManager.getFileByDecryptedRemotePath(config.folderPath) ?: return null + val folder = storageManager.getFileByDecryptedRemotePath(folderPath) ?: return null val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder) - val imageFiles = allFiles.filter { isImageFile(it) }.shuffled() + // IMPLEMENTATION OF "SMART MIX" STRATEGY + // 1. "On This Day": Photos from today's date in past years + val onThisDayFiles = allFiles.filter { isOnThisDay(it.modificationTimestamp) } - for (file in imageFiles) { - val bitmap = getThumbnailForFile(file, config.accountName) - if (bitmap != null) { - val geo = file.geoLocation - return PhotoWidgetImageResult( - bitmap = bitmap, - latitude = geo?.latitude, - longitude = geo?.longitude, - modificationTimestamp = file.modificationTimestamp - ) + // 2. "Recent": Top 20 newest photos + val recentFiles = allFiles.sortedByDescending { it.modificationTimestamp }.take(20) + + // 3. "Random": 10 random files from the rest to add variety + val usedIds = (onThisDayFiles + recentFiles).map { it.remoteId }.toSet() + val remainingFiles = allFiles.filter { !usedIds.contains(it.remoteId) } + val randomFiles = remainingFiles.shuffled().take(10) + + // Combine all candidates + val candidatePool = (onThisDayFiles + recentFiles + randomFiles).filter { isImageFile(it) } + + if (candidatePool.isEmpty()) { + return null + } + + // Prioritize images that are already downloaded or cached to avoid network timeouts + val (offlineFiles, onlineFiles) = candidatePool.partition { file -> + file.isDown || ThumbnailsCacheManager.containsBitmap(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) + } + + // 80% chance to pick from offline files if available, otherwise fallback to online + // This keeps the "randomness" but strongly biases towards instant loading + var candidateFile = if (offlineFiles.isNotEmpty() && (percentage(80) || onlineFiles.isEmpty())) { + offlineFiles.random() + } else if (onlineFiles.isNotEmpty()) { + onlineFiles.random() + } else { + candidatePool.random() + } + + var bitmap = getThumbnailForFile(candidateFile, accountName) + + // FAILSAFE: If the selected candidate failed (e.g. download error or missing file), + // and we have offline files available, try one of them instead of showing nothing. + if (bitmap == null && offlineFiles.isNotEmpty()) { + Log_OC.d(TAG, "Failed to load candidate image (isDown=${candidateFile.isDown}), trying fallback from ${offlineFiles.size} offline files") + // Try up to 3 random offline files to find a working one + for (i in 0 until 3) { + val fallback = offlineFiles.random() + bitmap = getThumbnailForFile(fallback, accountName) + if (bitmap != null) { + candidateFile = fallback + break + } } } - return null + + if (bitmap == null) { + Log_OC.e(TAG, "Failed to load any widget image") + return null + } + + // Update cache history and cleanup old entries + manageWidgetCache(widgetId, candidateFile, allFiles) + + val geo = candidateFile.geoLocation + return PhotoWidgetImageResult( + bitmap = bitmap, + latitude = geo?.latitude, + longitude = geo?.longitude, + modificationTimestamp = candidateFile.modificationTimestamp + ) + } + + private fun isOnThisDay(timestamp: Long): Boolean { + val calendar = java.util.Calendar.getInstance() + val todayMonth = calendar.get(java.util.Calendar.MONTH) + val todayDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + calendar.timeInMillis = timestamp + val fileMonth = calendar.get(java.util.Calendar.MONTH) + val fileDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + return (todayMonth == fileMonth && todayDay == fileDay) + } + + private fun manageWidgetCache(widgetId: Int, newFile: OCFile, allFiles: List) { + val prefKey = "${PREF_PREFIX}history_$widgetId" + val historyString = preferences.getString(prefKey, "") ?: "" + val history = if (historyString.isNotEmpty()) { + historyString.split(",").toMutableList() + } else { + mutableListOf() + } + + // Add new file ID if not present (move to end if present) + val newId = newFile.remoteId + if (history.contains(newId)) { + history.remove(newId) + } + history.add(newId) + + // Enforce limit of 10 + while (history.size > 10) { + val oldId = history.removeAt(0) + // Find the file to remove it from cache + val fileToRemove = allFiles.find { it.remoteId == oldId } + if (fileToRemove != null) { + Log_OC.d(TAG, "Evicting old widget image from cache: ${fileToRemove.fileName}") + ThumbnailsCacheManager.removeFromCache(fileToRemove) + } else { + Log_OC.d(TAG, "Could not find file object for eviction: $oldId") + } + } + + // Save updated history + preferences.edit().putString(prefKey, history.joinToString(",")).apply() + } + + private fun percentage(chance: Int): Boolean { + return (Math.random() * 100).toInt() < chance } @Suppress("MagicNumber") @@ -217,6 +319,10 @@ class PhotoWidgetRepository @Inject constructor( newWidth = (MAX_BITMAP_DIMENSION * ratio).toInt() } - return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + val scaledBitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + if (scaledBitmap != bitmap) { + bitmap.recycle() + } + return scaledBitmap } } diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 7a289b35da1e..620610ee1d1c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -17,10 +17,13 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Background worker that fetches a random photo and updates all photo widgets. @@ -52,7 +55,7 @@ class PhotoWidgetWorker( return Result.success() } - private fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { + private suspend fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) val imageResult = photoWidgetRepository.getRandomImageResult(widgetId) @@ -100,6 +103,9 @@ class PhotoWidgetWorker( nextIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Hide loading and show next button + remoteViews.setViewVisibility(R.id.photo_widget_loading, android.view.View.GONE) + remoteViews.setViewVisibility(R.id.photo_widget_next_button, android.view.View.VISIBLE) remoteViews.setOnClickPendingIntent(R.id.photo_widget_next_button, nextPendingIntent) appWidgetManager.updateAppWidget(widgetId, remoteViews) @@ -110,12 +116,21 @@ class PhotoWidgetWorker( * Returns null if geocoding is unavailable or coordinates are missing. */ @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 + private suspend fun resolveLocationName(latitude: Double?, longitude: Double?): String? = withContext(Dispatchers.IO) { + if (latitude == null || longitude == null) { + Log_OC.d(TAG, "Location resolution skipped: latitude=$latitude, longitude=$longitude") + return@withContext null + } + if (latitude == 0.0 && longitude == 0.0) { + Log_OC.d(TAG, "Location resolution skipped: coordinates are 0.0, 0.0") + return@withContext null + } - return try { - if (!Geocoder.isPresent()) return null + try { + if (!Geocoder.isPresent()) { + Log_OC.e(TAG, "Location resolution failed: Geocoder not present") + return@withContext null + } val geocoder = Geocoder(context, Locale.getDefault()) val addresses = geocoder.getFromLocation(latitude, longitude, 1) if (addresses != null && addresses.isNotEmpty()) { @@ -123,6 +138,7 @@ class PhotoWidgetWorker( // Build a concise location: "City, Country" or just "Country" val city = address.locality ?: address.subAdminArea val country = address.countryName + Log_OC.d(TAG, "Location resolved: city=$city, country=$country") when { city != null && country != null -> "$city, $country" city != null -> city @@ -130,9 +146,11 @@ class PhotoWidgetWorker( else -> null } } else { + Log_OC.d(TAG, "Location resolution: No address found for $latitude, $longitude") null } } catch (e: Exception) { + Log_OC.e(TAG, "Location resolution failed", e) null } } @@ -145,6 +163,10 @@ class PhotoWidgetWorker( 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) + if (config != null) { + intent.putExtra("folderPath", config.folderPath) + intent.putExtra("accountName", config.accountName) + } return intent } } diff --git a/app/src/main/res/drawable/bg_widget_button_circle.xml b/app/src/main/res/drawable/bg_widget_button_circle.xml new file mode 100644 index 000000000000..18163d9d70f7 --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_button_circle.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml index a5f255f3b46b..0754ac6656ff 100644 --- a/app/src/main/res/layout/widget_photo.xml +++ b/app/src/main/res/layout/widget_photo.xml @@ -66,17 +66,30 @@ - + + android:background="@drawable/bg_widget_button_circle" + android:tint="@android:color/white" + android:src="@drawable/ic_action_refresh" /> + + + diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt index 0c1b3fed671e..204679be62fa 100644 --- a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -50,12 +50,15 @@ class PhotoWidgetRepositoryTest { repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) } + @Test @Test fun `saveWidgetConfig stores folder path and account name`() { - repository.saveWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME) + val config = PhotoWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME, 15L) + repository.saveWidgetConfig(config) 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).putLong(eq("photo_widget_interval_minutes_$WIDGET_ID"), eq(15L)) verify(editor).apply() } From fb7dd45d922bad97ebba4f109cf109d12dde52ff Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:50:36 +0700 Subject: [PATCH 11/20] fix: address PR review and Codacy static analysis issues Signed-off-by: szyxxx --- .../client/widget/photo/PhotoWidgetConfigActivity.kt | 1 + .../com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt | 1 + .../nextcloud/client/widget/photo/PhotoWidgetRepository.kt | 4 +++- .../com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt | 4 +++- .../client/widget/photo/PhotoWidgetRepositoryTest.kt | 4 ++-- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index a00a3c19638c..a8ca93b80449 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -26,6 +26,7 @@ import javax.inject.Inject * Opens [FolderPickerActivity] for folder selection, then shows an interval * picker dialog, saves the config, and triggers an immediate widget update. */ +@Suppress("TooManyFunctions") class PhotoWidgetConfigActivity : Activity() { companion object { diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt index a8472f5a26ca..a726f2f7518c 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt @@ -19,6 +19,7 @@ import javax.inject.Inject * * Delegates heavy work to [PhotoWidgetWorker] via [BackgroundJobManager]. */ +@Suppress("TooManyFunctions") class PhotoWidgetProvider : AppWidgetProvider() { companion object { diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index cc09d84b4ae9..0e898cb17017 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -41,6 +41,7 @@ data class PhotoWidgetImageResult( * - Query FileDataStorageManager for image files in the selected folder * - Pick a random image and return a cached thumbnail Bitmap */ +@Suppress("MagicNumber", "TooManyFunctions") class PhotoWidgetRepository @Inject constructor( private val preferences: SharedPreferences, private val userAccountManager: UserAccountManager, @@ -98,6 +99,7 @@ class PhotoWidgetRepository @Inject constructor( * This ensures the widget falls back to cached/local images when the network * connection is poor, rather than showing a placeholder. */ + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { val config = getWidgetConfig(widgetId) ?: return null val folderPath = config.folderPath @@ -225,7 +227,6 @@ class PhotoWidgetRepository @Inject constructor( return (Math.random() * 100).toInt() < chance } - @Suppress("MagicNumber") private fun isImageFile(file: OCFile): Boolean { val mimeType = file.mimeType ?: return false return mimeType.startsWith("image/") @@ -262,6 +263,7 @@ class PhotoWidgetRepository @Inject constructor( return downloadThumbnail(file, imageKey, accountName) } + @Suppress("TooGenericExceptionCaught") private fun downloadThumbnail(file: OCFile, cacheKey: String, accountName: String): Bitmap? { val user = userAccountManager.getUser(accountName).orElse(null) ?: return null val client = OwnCloudClientManagerFactory.getDefaultSingleton() diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 620610ee1d1c..6ffe9dd6cf9b 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext * * Constructed by [com.nextcloud.client.jobs.BackgroundJobFactory]. */ +@Suppress("TooManyFunctions") class PhotoWidgetWorker( private val context: Context, params: WorkerParameters, @@ -55,6 +56,7 @@ class PhotoWidgetWorker( return Result.success() } + @Suppress("LongMethod") private suspend fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) { val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo) @@ -115,7 +117,7 @@ class PhotoWidgetWorker( * Reverse-geocodes lat/long into a human-readable location name. * Returns null if geocoding is unavailable or coordinates are missing. */ - @Suppress("DEPRECATION") + @Suppress("DEPRECATION", "TooGenericExceptionCaught", "ReturnCount", "CyclomaticComplexMethod") private suspend fun resolveLocationName(latitude: Double?, longitude: Double?): String? = withContext(Dispatchers.IO) { if (latitude == null || longitude == null) { Log_OC.d(TAG, "Location resolution skipped: latitude=$latitude, longitude=$longitude") diff --git a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt index 204679be62fa..ba210f28d3ae 100644 --- a/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt @@ -46,13 +46,13 @@ class PhotoWidgetRepositoryTest { MockitoAnnotations.openMocks(this) whenever(preferences.edit()).thenReturn(editor) whenever(editor.putString(anyString(), anyString())).thenReturn(editor) + whenever(editor.putLong(anyString(), org.mockito.ArgumentMatchers.anyLong())).thenReturn(editor) whenever(editor.remove(anyString())).thenReturn(editor) repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver) } @Test - @Test - fun `saveWidgetConfig stores folder path and account name`() { + fun `saveWidgetConfig stores folder path account name and interval`() { val config = PhotoWidgetConfig(WIDGET_ID, FOLDER_PATH, ACCOUNT_NAME, 15L) repository.saveWidgetConfig(config) From fc29538ed4f6ebbea259050a392246d62e056a77 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 02:57:01 +0700 Subject: [PATCH 12/20] fix: add date validity check and use thread-safe DateTimeFormatter Signed-off-by: szyxxx --- .../client/widget/photo/PhotoWidgetWorker.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 6ffe9dd6cf9b..4bd730ede864 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -19,8 +19,9 @@ import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -76,9 +77,15 @@ class PhotoWidgetWorker( remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) } - // Date line - val dateText = formatDate(imageResult.modificationTimestamp) - remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + // Date line (only if timestamp is valid) + val timestamp = imageResult.modificationTimestamp + if (timestamp > 0L) { + val dateText = formatDate(timestamp) + remoteViews.setTextViewText(R.id.photo_widget_date, dateText) + remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.VISIBLE) + } else { + remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.GONE) + } } else { remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) @@ -158,8 +165,9 @@ class PhotoWidgetWorker( } private fun formatDate(timestampMillis: Long): String { - val sdf = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()) - return sdf.format(Date(timestampMillis)) + val formatter = DateTimeFormatter.ofPattern(DATE_FORMAT, Locale.getDefault()) + val instant = Instant.ofEpochMilli(timestampMillis) + return formatter.format(instant.atZone(ZoneId.systemDefault())) } private fun createOpenFolderIntent(config: PhotoWidgetConfig?): Intent { From b71cf9e73662e8c2503a8ba3e94d4f7571df1373 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 03:18:29 +0700 Subject: [PATCH 13/20] ci: add auto-sync upstream and auto-build release workflows Signed-off-by: szyxxx --- .github/workflows/build-release.yml | 120 ++++++++++++++++++++++++++++ .github/workflows/sync-upstream.yml | 73 +++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/sync-upstream.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 000000000000..699374cb6b45 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: 2026 Axel +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Build & Release APK" + +on: + push: + branches: [master] + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + +concurrency: + group: build-release + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 21 + + - name: Configure Gradle + run: | + mkdir -p "$HOME/.gradle" + cat >> gradle.properties < /tmp/release.keystore + APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) + $APKSIGNER sign \ + --ks /tmp/release.keystore \ + --ks-pass "pass:$KEYSTORE_PASSWORD" \ + --key-pass "pass:$KEY_PASSWORD" \ + --ks-key-alias release \ + app/build/outputs/apk/generic/release/*.apk + rm /tmp/release.keystore + + - name: Sign APK (debug fallback) + if: ${{ env.KEYSTORE_BASE64 == '' }} + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + # Use the default debug keystore if no release keystore is configured + APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) + DEBUG_KS="$HOME/.android/debug.keystore" + if [ ! -f "$DEBUG_KS" ]; then + keytool -genkey -v \ + -keystore "$DEBUG_KS" \ + -storepass android \ + -alias androiddebugkey \ + -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + # Find the unsigned APK + APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) + # zipalign first + ZIPALIGN=$(find "$ANDROID_HOME/build-tools" -name zipalign | sort | tail -n1) + $ZIPALIGN -f 4 "$APK" "${APK%.apk}-aligned.apk" + mv "${APK%.apk}-aligned.apk" "$APK" + $APKSIGNER sign \ + --ks "$DEBUG_KS" \ + --ks-pass pass:android \ + --key-pass pass:android \ + --ks-key-alias androiddebugkey \ + "$APK" + + - name: Get version info + id: version + run: | + APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) + VERSION=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts | head -1 || echo "dev") + DATE=$(date -u +"%Y-%m-%d") + echo "apk_path=$APK" >> "$GITHUB_OUTPUT" + echo "apk_name=$(basename $APK)" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "date=$DATE" >> "$GITHUB_OUTPUT" + + - name: Create/Update Release + uses: softprops/action-gh-release@v2 + with: + tag_name: latest + name: "Nextcloud Photos Widget — ${{ steps.version.outputs.date }}" + body: | + Auto-built from `master` on ${{ steps.version.outputs.date }}. + + **Version**: ${{ steps.version.outputs.version }} + **Commit**: ${{ github.sha }} + + Install via [Obtainium](https://github.com/ImranR98/Obtainium) for automatic updates. + draft: false + prerelease: false + make_latest: true + files: ${{ steps.version.outputs.apk_path }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000000..b64a5cd45a94 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Axel +# SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + +name: "Sync Upstream" + +on: + schedule: + # Run daily at 04:00 UTC (11:00 WIB) + - cron: "0 4 * * *" + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + issues: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: git remote add upstream https://github.com/nextcloud/android.git || true + + - name: Fetch upstream + run: git fetch upstream master + + - name: Rebase on upstream + id: rebase + run: | + git rebase upstream/master + echo "success=true" >> "$GITHUB_OUTPUT" + continue-on-error: true + + - name: Handle rebase failure + if: steps.rebase.outputs.success != 'true' + run: | + git rebase --abort + echo "::error::Rebase failed due to conflicts. Manual resolution needed." + + - name: Push changes + if: steps.rebase.outputs.success == 'true' + run: git push --force-with-lease origin master + + - name: Create issue on failure + if: steps.rebase.outputs.success != 'true' + uses: actions/github-script@v7 + with: + script: | + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-conflict' + }); + if (existing.data.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⚠️ Upstream sync failed — rebase conflict', + body: 'The daily upstream sync failed due to merge conflicts.\n\nPlease resolve manually:\n```bash\ngit fetch upstream master\ngit rebase upstream/master\n# resolve conflicts\ngit push --force-with-lease origin master\n```', + labels: ['sync-conflict'] + }); + } From 8daed6f293e9a77181469642414f1e9cb01983f2 Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 11:39:44 +0700 Subject: [PATCH 14/20] feat: implement photo widget worker and its layout to display random photos with location and refresh functionality. --- .../client/widget/photo/PhotoWidgetWorker.kt | 20 ------------------- app/src/main/res/layout/widget_photo.xml | 16 --------------- 2 files changed, 36 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 4bd730ede864..c09314197c27 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -19,9 +19,6 @@ import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -41,7 +38,6 @@ class PhotoWidgetWorker( companion object { const val TAG = "PhotoWidgetWorker" - private const val DATE_FORMAT = "dd MMM yyyy" private const val NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000 } @@ -76,16 +72,6 @@ class PhotoWidgetWorker( } else { remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) } - - // Date line (only if timestamp is valid) - val timestamp = imageResult.modificationTimestamp - if (timestamp > 0L) { - val dateText = formatDate(timestamp) - remoteViews.setTextViewText(R.id.photo_widget_date, dateText) - remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.VISIBLE) - } else { - remoteViews.setViewVisibility(R.id.photo_widget_date, android.view.View.GONE) - } } else { remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) @@ -164,12 +150,6 @@ class PhotoWidgetWorker( } } - private fun formatDate(timestampMillis: Long): String { - val formatter = DateTimeFormatter.ofPattern(DATE_FORMAT, Locale.getDefault()) - val instant = Instant.ofEpochMilli(timestampMillis) - return formatter.format(instant.atZone(ZoneId.systemDefault())) - } - 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) diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml index 0754ac6656ff..e087f7032106 100644 --- a/app/src/main/res/layout/widget_photo.xml +++ b/app/src/main/res/layout/widget_photo.xml @@ -48,22 +48,6 @@ tools:text="Jakarta, Indonesia" tools:visibility="visible" /> - - From f54d752a6761e4e84f259406ae8db46797833dce Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 13:09:06 +0700 Subject: [PATCH 15/20] fix(ci): rewrite build workflow - unique tags, fix gradle config, use debug build Signed-off-by: szyxxx --- .github/workflows/build-release.yml | 100 ++++++++-------------------- 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 699374cb6b45..d88887edfd06 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -30,91 +30,47 @@ jobs: - name: Configure Gradle run: | - mkdir -p "$HOME/.gradle" - cat >> gradle.properties <> gradle.properties + echo "org.gradle.caching=true" >> gradle.properties + echo "org.gradle.parallel=true" >> gradle.properties + echo "org.gradle.configureondemand=true" >> gradle.properties + echo "kapt.incremental.apt=true" >> gradle.properties - - name: Build Generic Release APK - run: ./gradlew assembleGenericRelease + - name: Build Generic Debug APK + run: ./gradlew assembleGenericDebug - - name: Sign APK - if: ${{ env.KEYSTORE_BASE64 != '' }} - env: - KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - run: | - echo "$KEYSTORE_BASE64" | base64 -d > /tmp/release.keystore - APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) - $APKSIGNER sign \ - --ks /tmp/release.keystore \ - --ks-pass "pass:$KEYSTORE_PASSWORD" \ - --key-pass "pass:$KEY_PASSWORD" \ - --ks-key-alias release \ - app/build/outputs/apk/generic/release/*.apk - rm /tmp/release.keystore - - - name: Sign APK (debug fallback) - if: ${{ env.KEYSTORE_BASE64 == '' }} - env: - KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + - name: Prepare release info + id: info run: | - # Use the default debug keystore if no release keystore is configured - APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -n1) - DEBUG_KS="$HOME/.android/debug.keystore" - if [ ! -f "$DEBUG_KS" ]; then - keytool -genkey -v \ - -keystore "$DEBUG_KS" \ - -storepass android \ - -alias androiddebugkey \ - -keypass android \ - -keyalg RSA -keysize 2048 -validity 10000 \ - -dname "CN=Android Debug,O=Android,C=US" - fi - # Find the unsigned APK - APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) - # zipalign first - ZIPALIGN=$(find "$ANDROID_HOME/build-tools" -name zipalign | sort | tail -n1) - $ZIPALIGN -f 4 "$APK" "${APK%.apk}-aligned.apk" - mv "${APK%.apk}-aligned.apk" "$APK" - $APKSIGNER sign \ - --ks "$DEBUG_KS" \ - --ks-pass pass:android \ - --key-pass pass:android \ - --ks-key-alias androiddebugkey \ - "$APK" + APK=$(find app/build/outputs/apk/generic/debug -name "*.apk" | head -1) + DATE=$(date -u +"%Y%m%d-%H%M%S") + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + TAG="v${DATE}-${SHORT_SHA}" + echo "apk_path=$APK" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "date=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" - - name: Get version info - id: version + - name: Rename APK run: | - APK=$(find app/build/outputs/apk/generic/release -name "*.apk" | head -1) - VERSION=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts | head -1 || echo "dev") - DATE=$(date -u +"%Y-%m-%d") - echo "apk_path=$APK" >> "$GITHUB_OUTPUT" - echo "apk_name=$(basename $APK)" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "date=$DATE" >> "$GITHUB_OUTPUT" + APK="${{ steps.info.outputs.apk_path }}" + DIR=$(dirname "$APK") + cp "$APK" "${DIR}/nextcloud-photo-widget-${{ steps.info.outputs.tag }}.apk" + echo "final_apk=${DIR}/nextcloud-photo-widget-${{ steps.info.outputs.tag }}.apk" >> "$GITHUB_OUTPUT" + id: rename - - name: Create/Update Release + - name: Create Release uses: softprops/action-gh-release@v2 with: - tag_name: latest - name: "Nextcloud Photos Widget — ${{ steps.version.outputs.date }}" + tag_name: ${{ steps.info.outputs.tag }} + name: "Photo Widget — ${{ steps.info.outputs.date }}" body: | - Auto-built from `master` on ${{ steps.version.outputs.date }}. - - **Version**: ${{ steps.version.outputs.version }} - **Commit**: ${{ github.sha }} + Auto-built from `master` on ${{ steps.info.outputs.date }}. + **Commit**: `${{ github.sha }}` Install via [Obtainium](https://github.com/ImranR98/Obtainium) for automatic updates. draft: false prerelease: false make_latest: true - files: ${{ steps.version.outputs.apk_path }} + files: ${{ steps.rename.outputs.final_apk }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From ff5b767d78dbb1ce2933b3d2d1878e76efee5cff Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 15:39:57 +0700 Subject: [PATCH 16/20] feat: Add a new photo widget with configurable image and video display. --- app/build.gradle.kts | 4 + .../client/jobs/BackgroundJobManagerImpl.kt | 5 + .../widget/photo/PhotoWidgetConfigActivity.kt | 37 ++- .../photo/PhotoWidgetConfigViewModel.kt | 61 +++++ .../widget/photo/PhotoWidgetRepository.kt | 241 +++++++++++++++--- .../client/widget/photo/PhotoWidgetWorker.kt | 86 ++++++- .../res/drawable/bg_widget_gradient_scrim.xml | 14 + app/src/main/res/layout/widget_photo.xml | 66 ++++- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/photo_widget_info.xml | 8 + gradle/libs.versions.toml | 2 + gradle/verification-metadata.xml | 1 + 12 files changed, 458 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigViewModel.kt create mode 100644 app/src/main/res/drawable/bg_widget_gradient_scrim.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 248f60acfc60..1c6988ae053d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -377,6 +377,10 @@ dependencies { implementation(libs.work.runtime.ktx) // endregion + // region Photo Widget + implementation(libs.palette) + // endregion + // region Lifecycle implementation(libs.lifecycle.viewmodel.ktx) implementation(libs.lifecycle.service) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index e3c3bfcf0f63..5f91bd08c06b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -841,6 +841,11 @@ internal class BackgroundJobManagerImpl( intervalMins = intervalMinutes ) .setConstraints(constraints) + .setBackoffCriteria( + androidx.work.BackoffPolicy.EXPONENTIAL, + 30L, + java.util.concurrent.TimeUnit.SECONDS + ) .build() workManager.enqueueUniquePeriodicWork( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index a8ca93b80449..200055343b3b 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.os.Build import android.os.Bundle import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.jobs.BackgroundJobManager import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.activity.FolderPickerActivity @@ -21,7 +20,8 @@ import dagger.android.AndroidInjection import javax.inject.Inject /** - * Configuration activity launched when the user places a Photo Widget. + * Configuration activity launched when the user places a Photo Widget + * or reconfigures an existing one (Android 12+). * * Opens [FolderPickerActivity] for folder selection, then shows an interval * picker dialog, saves the config, and triggers an immediate widget update. @@ -34,10 +34,7 @@ class PhotoWidgetConfigActivity : Activity() { } @Inject - lateinit var photoWidgetRepository: PhotoWidgetRepository - - @Inject - lateinit var backgroundJobManager: BackgroundJobManager + lateinit var viewModel: PhotoWidgetConfigViewModel @Inject lateinit var userAccountManager: UserAccountManager @@ -62,6 +59,8 @@ class PhotoWidgetConfigActivity : Activity() { return } + viewModel.setWidgetId(appWidgetId) + // Launch FolderPickerActivity for folder selection val folderPickerIntent = Intent(this, FolderPickerActivity::class.java).apply { putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) @@ -83,6 +82,7 @@ class PhotoWidgetConfigActivity : Activity() { } if (folder != null) { + viewModel.setSelectedFolder(folder) showIntervalPicker(folder) } else { finish() @@ -109,6 +109,15 @@ class PhotoWidgetConfigActivity : Activity() { // Default selection: 15 minutes (index 1) var selectedIndex = 1 + // If reconfiguring, use existing interval as default + val existingConfig = viewModel.getExistingConfig() + if (existingConfig != null) { + val existingIndex = values.indexOf(existingConfig.intervalMinutes) + if (existingIndex >= 0) { + selectedIndex = existingIndex + } + } + AlertDialog.Builder(this) .setTitle(getString(R.string.photo_widget_interval_title)) .setSingleChoiceItems(labels, selectedIndex) { _, which -> @@ -131,20 +140,8 @@ class PhotoWidgetConfigActivity : Activity() { val folderPath = folder.remotePath val accountName = userAccountManager.user.accountName - // Save configuration (including interval) - val config = PhotoWidgetConfig(appWidgetId, folderPath, accountName, intervalMinutes) - photoWidgetRepository.saveWidgetConfig(config) - - // 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"). - backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) - - // Trigger immediate widget update - backgroundJobManager.startImmediatePhotoWidgetUpdate() + // Delegate all business logic to ViewModel + viewModel.saveConfiguration(folderPath, accountName, intervalMinutes) // Return success val resultValue = Intent().apply { diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigViewModel.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigViewModel.kt new file mode 100644 index 000000000000..0e77b338c0d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigViewModel.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Axel + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.widget.photo + +import androidx.lifecycle.ViewModel +import com.nextcloud.client.jobs.BackgroundJobManager +import com.owncloud.android.datamodel.OCFile +import javax.inject.Inject + +/** + * ViewModel for [PhotoWidgetConfigActivity]. + * + * Manages folder selection state, interval selection, and config saving. + * Separates business logic from the Activity lifecycle. + */ +class PhotoWidgetConfigViewModel @Inject constructor( + private val photoWidgetRepository: PhotoWidgetRepository, + private val backgroundJobManager: BackgroundJobManager +) : ViewModel() { + + private var selectedFolder: OCFile? = null + private var widgetId: Int = -1 + + fun setWidgetId(id: Int) { + widgetId = id + } + + fun getWidgetId(): Int = widgetId + + fun setSelectedFolder(folder: OCFile) { + selectedFolder = folder + } + + fun getSelectedFolder(): OCFile? = selectedFolder + + /** + * Saves the widget configuration and schedules update jobs. + * + * @param folderPath The remote path of the selected folder + * @param accountName The account name of the user + * @param intervalMinutes The refresh interval in minutes (0 = manual only) + */ + fun saveConfiguration(folderPath: String, accountName: String, intervalMinutes: Long) { + val config = PhotoWidgetConfig(widgetId, folderPath, accountName, intervalMinutes) + photoWidgetRepository.saveWidgetConfig(config) + backgroundJobManager.schedulePeriodicPhotoWidgetUpdate(intervalMinutes) + backgroundJobManager.startImmediatePhotoWidgetUpdate() + } + + /** + * Returns the existing config for this widget, if any. + * Useful for reconfigure flow (Android 12+). + */ + fun getExistingConfig(): PhotoWidgetConfig? { + return photoWidgetRepository.getWidgetConfig(widgetId) + } +} diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index 0e898cb17017..c1ebe94e4d50 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -9,6 +9,10 @@ package com.nextcloud.client.widget.photo import android.content.ContentResolver import android.content.SharedPreferences import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.media.MediaMetadataRetriever import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager @@ -18,6 +22,8 @@ import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.BitmapUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.apache.commons.httpclient.HttpStatus import org.apache.commons.httpclient.methods.GetMethod import java.io.InputStream @@ -30,7 +36,10 @@ data class PhotoWidgetImageResult( val bitmap: Bitmap, val latitude: Double?, val longitude: Double?, - val modificationTimestamp: Long + val modificationTimestamp: Long, + val isVideo: Boolean = false, + val fileRemotePath: String? = null, + val fileId: Long? = null ) /** @@ -40,6 +49,8 @@ data class PhotoWidgetImageResult( * - Save / delete / retrieve per-widget config in SharedPreferences * - Query FileDataStorageManager for image files in the selected folder * - Pick a random image and return a cached thumbnail Bitmap + * - Pre-fetch the next candidate for instant loading + * - Extract video thumbnails with play icon overlay */ @Suppress("MagicNumber", "TooManyFunctions") class PhotoWidgetRepository @Inject constructor( @@ -54,10 +65,18 @@ class PhotoWidgetRepository @Inject constructor( private const val PREF_FOLDER_PATH = "${PREF_PREFIX}folder_path_" private const val PREF_ACCOUNT_NAME = "${PREF_PREFIX}account_name_" private const val PREF_INTERVAL_MINUTES = "${PREF_PREFIX}interval_minutes_" - private const val MAX_BITMAP_DIMENSION = 800 // Increased from 512 for better quality - private const val SERVER_REQUEST_DIMENSION = 2048 // Request high-res preview from server + private const val PREF_FILE_COUNT = "${PREF_PREFIX}file_count_" + private const val MAX_BITMAP_DIMENSION = 800 + private const val SERVER_REQUEST_DIMENSION = 2048 private const val READ_TIMEOUT = 40000 private const val CONNECTION_TIMEOUT = 5000 + private const val CACHE_HISTORY_LIMIT = 10 + private const val OFFLINE_BIAS_PERCENT = 80 + private const val OFFLINE_FALLBACK_ATTEMPTS = 3 + private const val RECENT_FILES_COUNT = 20 + private const val RANDOM_FILES_COUNT = 10 + private const val MAX_VIDEO_CACHE = 2 + private const val PLAY_ICON_SIZE_RATIO = 0.15f } fun getWidgetConfig(widgetId: Int): PhotoWidgetConfig? { @@ -80,6 +99,8 @@ class PhotoWidgetRepository @Inject constructor( .remove(PREF_FOLDER_PATH + widgetId) .remove(PREF_ACCOUNT_NAME + widgetId) .remove(PREF_INTERVAL_MINUTES + widgetId) + .remove(PREF_FILE_COUNT + widgetId) + .remove("${PREF_PREFIX}history_$widgetId") .apply() } @@ -95,9 +116,9 @@ class PhotoWidgetRepository @Inject constructor( /** * Returns a random image result with bitmap and metadata, or `null` on failure. * - * Shuffles all image files and tries each one until a thumbnail loads successfully. - * This ensures the widget falls back to cached/local images when the network - * connection is poor, rather than showing a placeholder. + * Uses a "Smart Mix" strategy combining On This Day, Recent, and Random files. + * Supports both image and video files (videos show as thumbnail + ▶ overlay). + * Pre-fetches the next candidate for instant loading. */ @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? { @@ -110,20 +131,23 @@ class PhotoWidgetRepository @Inject constructor( val folder = storageManager.getFileByDecryptedRemotePath(folderPath) ?: return null val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder) + // Cache invalidation: if file count changed, clear stale cache + invalidateCacheIfNeeded(widgetId, allFiles) + // IMPLEMENTATION OF "SMART MIX" STRATEGY // 1. "On This Day": Photos from today's date in past years val onThisDayFiles = allFiles.filter { isOnThisDay(it.modificationTimestamp) } // 2. "Recent": Top 20 newest photos - val recentFiles = allFiles.sortedByDescending { it.modificationTimestamp }.take(20) + val recentFiles = allFiles.sortedByDescending { it.modificationTimestamp }.take(RECENT_FILES_COUNT) // 3. "Random": 10 random files from the rest to add variety val usedIds = (onThisDayFiles + recentFiles).map { it.remoteId }.toSet() val remainingFiles = allFiles.filter { !usedIds.contains(it.remoteId) } - val randomFiles = remainingFiles.shuffled().take(10) + val randomFiles = remainingFiles.shuffled().take(RANDOM_FILES_COUNT) - // Combine all candidates - val candidatePool = (onThisDayFiles + recentFiles + randomFiles).filter { isImageFile(it) } + // Combine all candidates — include both images and videos + val candidatePool = (onThisDayFiles + recentFiles + randomFiles).filter { isMediaFile(it) } if (candidatePool.isEmpty()) { return null @@ -134,9 +158,8 @@ class PhotoWidgetRepository @Inject constructor( file.isDown || ThumbnailsCacheManager.containsBitmap(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) } - // 80% chance to pick from offline files if available, otherwise fallback to online - // This keeps the "randomness" but strongly biases towards instant loading - var candidateFile = if (offlineFiles.isNotEmpty() && (percentage(80) || onlineFiles.isEmpty())) { + // 80% chance to pick from offline files if available + var candidateFile = if (offlineFiles.isNotEmpty() && (percentage(OFFLINE_BIAS_PERCENT) || onlineFiles.isEmpty())) { offlineFiles.random() } else if (onlineFiles.isNotEmpty()) { onlineFiles.random() @@ -144,14 +167,17 @@ class PhotoWidgetRepository @Inject constructor( candidatePool.random() } - var bitmap = getThumbnailForFile(candidateFile, accountName) + val isVideo = isVideoFile(candidateFile) + var bitmap = if (isVideo) { + getVideoThumbnail(candidateFile) + } else { + getThumbnailForFile(candidateFile, accountName) + } - // FAILSAFE: If the selected candidate failed (e.g. download error or missing file), - // and we have offline files available, try one of them instead of showing nothing. + // FAILSAFE: If the selected candidate failed, try offline fallback if (bitmap == null && offlineFiles.isNotEmpty()) { - Log_OC.d(TAG, "Failed to load candidate image (isDown=${candidateFile.isDown}), trying fallback from ${offlineFiles.size} offline files") - // Try up to 3 random offline files to find a working one - for (i in 0 until 3) { + Log_OC.d(TAG, "Failed to load candidate, trying fallback from ${offlineFiles.size} offline files") + for (i in 0 until OFFLINE_FALLBACK_ATTEMPTS) { val fallback = offlineFiles.random() bitmap = getThumbnailForFile(fallback, accountName) if (bitmap != null) { @@ -166,18 +192,169 @@ class PhotoWidgetRepository @Inject constructor( return null } + // Add play icon overlay for video files + if (isVideo) { + bitmap = addPlayIconOverlay(bitmap) + } + // Update cache history and cleanup old entries manageWidgetCache(widgetId, candidateFile, allFiles) + // Pre-fetch next candidate in background + prefetchNextCandidate(candidatePool, candidateFile, accountName) + val geo = candidateFile.geoLocation return PhotoWidgetImageResult( bitmap = bitmap, latitude = geo?.latitude, longitude = geo?.longitude, - modificationTimestamp = candidateFile.modificationTimestamp + modificationTimestamp = candidateFile.modificationTimestamp, + isVideo = isVideo, + fileRemotePath = candidateFile.remotePath, + fileId = candidateFile.localId ) } + // --------------- Cache invalidation --------------- + + private fun invalidateCacheIfNeeded(widgetId: Int, allFiles: List) { + val prefKey = PREF_FILE_COUNT + widgetId + val lastKnownCount = preferences.getInt(prefKey, -1) + val currentCount = allFiles.size + + if (lastKnownCount != -1 && lastKnownCount != currentCount) { + Log_OC.d(TAG, "File count changed ($lastKnownCount → $currentCount), clearing stale cache") + val historyKey = "${PREF_PREFIX}history_$widgetId" + val historyString = preferences.getString(historyKey, "") ?: "" + val history = if (historyString.isNotEmpty()) historyString.split(",") else emptyList() + + // Remove cached entries for files that no longer exist + val existingIds = allFiles.map { it.remoteId }.toSet() + val staleIds = history.filter { !existingIds.contains(it) } + for (staleId in staleIds) { + ThumbnailsCacheManager.removeBitmapFromDiskCache("r$staleId") + ThumbnailsCacheManager.removeBitmapFromDiskCache("t$staleId") + Log_OC.d(TAG, "Evicted stale cache entry: $staleId") + } + + // Update history to remove stale entries + val cleanedHistory = history.filter { existingIds.contains(it) } + preferences.edit().putString(historyKey, cleanedHistory.joinToString(",")).apply() + } + + // Save current file count + preferences.edit().putInt(prefKey, currentCount).apply() + } + + // --------------- Pre-fetching --------------- + + private fun prefetchNextCandidate(candidates: List, current: OCFile, accountName: String) { + val nextCandidates = candidates.filter { it.remoteId != current.remoteId } + if (nextCandidates.isEmpty()) return + + val next = nextCandidates.random() + // Only pre-fetch if not already cached + val imageKey = "r" + next.remoteId + val thumbnailKey = "t" + next.remoteId + if (ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) != null || + ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey) != null) { + return + } + + Log_OC.d(TAG, "Pre-fetching next candidate: ${next.fileName}") + if (isVideoFile(next)) { + getVideoThumbnail(next) + } else { + getThumbnailForFile(next, accountName) + } + } + + // --------------- Video thumbnail support --------------- + + /** + * Extracts a video frame thumbnail using [MediaMetadataRetriever]. + * Only works for downloaded video files. Max 2 video thumbnails are cached. + */ + @Suppress("TooGenericExceptionCaught") + private fun getVideoThumbnail(file: OCFile): Bitmap? { + if (!file.isDown) { + Log_OC.d(TAG, "Video not downloaded, cannot extract thumbnail: ${file.fileName}") + return null + } + + // Check video cache count + val videoCacheKey = "video_${file.remoteId}" + val cached = ThumbnailsCacheManager.getBitmapFromDiskCache(videoCacheKey) + if (cached != null) return scaleBitmap(cached) + + try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(file.storagePath) + val frame = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + retriever.release() + + if (frame != null) { + // Enforce max 2 video thumbnails in cache + enforceVideoThumbnailLimit() + ThumbnailsCacheManager.addBitmapToCache(videoCacheKey, frame) + return scaleBitmap(frame) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Error extracting video thumbnail: ${file.fileName}", e) + } + return null + } + + private fun enforceVideoThumbnailLimit() { + val videoHistoryKey = "${PREF_PREFIX}video_cache_ids" + val historyString = preferences.getString(videoHistoryKey, "") ?: "" + val history = if (historyString.isNotEmpty()) historyString.split(",").toMutableList() else mutableListOf() + + while (history.size >= MAX_VIDEO_CACHE) { + val oldId = history.removeAt(0) + ThumbnailsCacheManager.removeBitmapFromDiskCache("video_$oldId") + Log_OC.d(TAG, "Evicted video thumbnail: $oldId") + } + + preferences.edit().putString(videoHistoryKey, history.joinToString(",")).apply() + } + + /** + * Draws a semi-transparent play icon (▶) onto the center of the bitmap. + */ + private fun addPlayIconOverlay(source: Bitmap): Bitmap { + val result = source.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(result) + val centerX = result.width / 2f + val centerY = result.height / 2f + val iconSize = result.width * PLAY_ICON_SIZE_RATIO + + // Semi-transparent circle background + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.argb(180, 0, 0, 0) + style = Paint.Style.FILL + } + canvas.drawCircle(centerX, centerY, iconSize, bgPaint) + + // White triangle play icon + val trianglePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.WHITE + style = Paint.Style.FILL + } + val triangleSize = iconSize * 0.6f + val path = Path().apply { + moveTo(centerX - triangleSize * 0.4f, centerY - triangleSize * 0.6f) + lineTo(centerX - triangleSize * 0.4f, centerY + triangleSize * 0.6f) + lineTo(centerX + triangleSize * 0.6f, centerY) + close() + } + canvas.drawPath(path, trianglePaint) + + return result + } + + // --------------- Smart Mix helpers --------------- + private fun isOnThisDay(timestamp: Long): Boolean { val calendar = java.util.Calendar.getInstance() val todayMonth = calendar.get(java.util.Calendar.MONTH) @@ -206,20 +383,18 @@ class PhotoWidgetRepository @Inject constructor( } history.add(newId) - // Enforce limit of 10 - while (history.size > 10) { + // Enforce limit + while (history.size > CACHE_HISTORY_LIMIT) { val oldId = history.removeAt(0) - // Find the file to remove it from cache val fileToRemove = allFiles.find { it.remoteId == oldId } if (fileToRemove != null) { Log_OC.d(TAG, "Evicting old widget image from cache: ${fileToRemove.fileName}") ThumbnailsCacheManager.removeFromCache(fileToRemove) } else { - Log_OC.d(TAG, "Could not find file object for eviction: $oldId") + Log_OC.d(TAG, "Could not find file object for eviction: $oldId") } } - // Save updated history preferences.edit().putString(prefKey, history.joinToString(",")).apply() } @@ -227,11 +402,18 @@ class PhotoWidgetRepository @Inject constructor( return (Math.random() * 100).toInt() < chance } - private fun isImageFile(file: OCFile): Boolean { + private fun isMediaFile(file: OCFile): Boolean { + val mimeType = file.mimeType ?: return false + return mimeType.startsWith("image/") || mimeType.startsWith("video/") + } + + private fun isVideoFile(file: OCFile): Boolean { val mimeType = file.mimeType ?: return false - return mimeType.startsWith("image/") + return mimeType.startsWith("video/") } + // --------------- Thumbnail retrieval --------------- + /** * Attempts to retrieve a cached thumbnail, or downloads it if missing. * Tries "resized" (large) cache first for quality. @@ -249,11 +431,9 @@ class PhotoWidgetRepository @Inject constructor( // 3. If missing, generate from local file if (file.isDown) { - // Generate high-quality local thumbnail bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, SERVER_REQUEST_DIMENSION, SERVER_REQUEST_DIMENSION) if (bitmap != null) { - // Cache as "resized" for future high-quality use - val keyToCache = imageKey // Cache as 'r' (resized) + val keyToCache = imageKey ThumbnailsCacheManager.addBitmapToCache(keyToCache, bitmap) return scaleBitmap(bitmap) } @@ -269,7 +449,6 @@ class PhotoWidgetRepository @Inject constructor( val client = OwnCloudClientManagerFactory.getDefaultSingleton() .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext()) - // Request high-res preview (2048px) val dimension = SERVER_REQUEST_DIMENSION val uri = client.baseUri.toString() + "/index.php/core/preview?fileId=" + file.localId + "&x=" + dimension + "&y=" + dimension + "&a=1&mode=cover&forceIcon=0" diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index c09314197c27..286c97076b28 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -13,6 +13,7 @@ import android.content.Context import android.content.Intent import android.location.Geocoder import android.widget.RemoteViews +import androidx.palette.graphics.Palette import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager @@ -39,6 +40,7 @@ class PhotoWidgetWorker( companion object { const val TAG = "PhotoWidgetWorker" private const val NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000 + private const val BRIGHTNESS_THRESHOLD = 128 } override suspend fun doWork(): Result { @@ -59,9 +61,13 @@ class PhotoWidgetWorker( val imageResult = photoWidgetRepository.getRandomImageResult(widgetId) if (imageResult != null) { + // Show photo, hide empty state + remoteViews.setViewVisibility(R.id.photo_widget_image, android.view.View.VISIBLE) + remoteViews.setViewVisibility(R.id.photo_widget_empty_state, android.view.View.GONE) remoteViews.setImageViewBitmap(R.id.photo_widget_image, imageResult.bitmap) - // Show the text container + // Show gradient scrim and text container + remoteViews.setViewVisibility(R.id.photo_widget_scrim, android.view.View.VISIBLE) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.VISIBLE) // Location line (only if geolocation is available) @@ -71,15 +77,24 @@ class PhotoWidgetWorker( remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.VISIBLE) } else { remoteViews.setViewVisibility(R.id.photo_widget_location, android.view.View.GONE) + // Hide scrim and text container if there's no text to show + remoteViews.setViewVisibility(R.id.photo_widget_scrim, android.view.View.GONE) + remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) } + + // Adaptive button tint: light icon on dark images, dark icon on light images + applyAdaptiveButtonTint(remoteViews, imageResult) } else { - remoteViews.setImageViewResource(R.id.photo_widget_image, R.drawable.ic_image_outline) + // Show empty state, hide photo + remoteViews.setViewVisibility(R.id.photo_widget_image, android.view.View.GONE) + remoteViews.setViewVisibility(R.id.photo_widget_empty_state, android.view.View.VISIBLE) + remoteViews.setViewVisibility(R.id.photo_widget_scrim, android.view.View.GONE) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) } - // Set click on photo to open the folder in FileDisplayActivity + // Set click on photo to open the specific file in FileDisplayActivity val config = photoWidgetRepository.getWidgetConfig(widgetId) - val clickIntent = createOpenFolderIntent(config) + val clickIntent = createOpenFileIntent(config, imageResult) val openPendingIntent = PendingIntent.getActivity( context, widgetId, @@ -106,6 +121,56 @@ class PhotoWidgetWorker( appWidgetManager.updateAppWidget(widgetId, remoteViews) } + /** + * Applies adaptive tint to the refresh button based on image brightness. + * Uses [Palette] to detect the dominant color in the top-right corner + * (where the button sits) and sets the button tint accordingly. + */ + @Suppress("MagicNumber") + private fun applyAdaptiveButtonTint(remoteViews: RemoteViews, imageResult: PhotoWidgetImageResult) { + try { + val bitmap = imageResult.bitmap + // Sample the top-right quadrant where the button lives + val sampleWidth = bitmap.width / 4 + val sampleHeight = bitmap.height / 4 + if (sampleWidth <= 0 || sampleHeight <= 0) return + + val cornerBitmap = android.graphics.Bitmap.createBitmap( + bitmap, + bitmap.width - sampleWidth, + 0, + sampleWidth, + sampleHeight + ) + + val palette = Palette.from(cornerBitmap).generate() + val dominantSwatch = palette.dominantSwatch + + if (dominantSwatch != null) { + val r = android.graphics.Color.red(dominantSwatch.rgb) + val g = android.graphics.Color.green(dominantSwatch.rgb) + val b = android.graphics.Color.blue(dominantSwatch.rgb) + // Perceived brightness formula + val brightness = (r * 0.299 + g * 0.587 + b * 0.114).toInt() + + val tintColor = if (brightness > BRIGHTNESS_THRESHOLD) { + // Light background → dark button + android.graphics.Color.parseColor("#CC333333") + } else { + // Dark background → light button + android.graphics.Color.WHITE + } + remoteViews.setInt(R.id.photo_widget_next_button, "setColorFilter", tintColor) + } + + if (cornerBitmap != bitmap) { + cornerBitmap.recycle() + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Log_OC.d(TAG, "Could not apply adaptive tint: ${e.message}") + } + } + /** * Reverse-geocodes lat/long into a human-readable location name. * Returns null if geocoding is unavailable or coordinates are missing. @@ -150,13 +215,24 @@ class PhotoWidgetWorker( } } - private fun createOpenFolderIntent(config: PhotoWidgetConfig?): Intent { + /** + * Creates an intent to open the specific file in FileDisplayActivity. + * Falls back to opening the folder if file info is not available. + */ + private fun createOpenFileIntent(config: PhotoWidgetConfig?, imageResult: PhotoWidgetImageResult?): Intent { val intent = Intent(context, FileDisplayActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) if (config != null) { intent.putExtra("folderPath", config.folderPath) intent.putExtra("accountName", config.accountName) } + // If we have the file ID, pass it so the activity opens the specific file + if (imageResult?.fileId != null) { + intent.putExtra("fileId", imageResult.fileId) + } + if (imageResult?.fileRemotePath != null) { + intent.putExtra("filePath", imageResult.fileRemotePath) + } return intent } } diff --git a/app/src/main/res/drawable/bg_widget_gradient_scrim.xml b/app/src/main/res/drawable/bg_widget_gradient_scrim.xml new file mode 100644 index 000000000000..da167c78014d --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_gradient_scrim.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/widget_photo.xml b/app/src/main/res/layout/widget_photo.xml index e087f7032106..0db137550664 100644 --- a/app/src/main/res/layout/widget_photo.xml +++ b/app/src/main/res/layout/widget_photo.xml @@ -8,17 +8,63 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/photo_widget_container" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:clipChildren="true"> + + android:visibility="gone" + tools:src="@drawable/ic_image_outline" + tools:visibility="visible" /> - + + + + + + + + + + + + + @@ -58,12 +100,12 @@ android:layout_gravity="top|end" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" + android:background="@drawable/bg_widget_button_circle" android:contentDescription="@string/photo_widget_next_image" android:padding="8dp" android:scaleType="fitCenter" - android:background="@drawable/bg_widget_button_circle" - android:tint="@android:color/white" - android:src="@drawable/ic_action_refresh" /> + android:src="@drawable/ic_action_refresh" + android:tint="@android:color/white" /> Every 30 minutes Every 60 minutes Manual only + Tap refresh to load photos Icon for empty list No items Check back later or reload. diff --git a/app/src/main/res/xml/photo_widget_info.xml b/app/src/main/res/xml/photo_widget_info.xml index bdcd5306f1e2..eb5c7fa73cb7 100644 --- a/app/src/main/res/xml/photo_widget_info.xml +++ b/app/src/main/res/xml/photo_widget_info.xml @@ -11,7 +11,15 @@ android:initialLayout="@layout/widget_photo" android:minWidth="110dp" android:minHeight="110dp" + android:minResizeWidth="80dp" + android:minResizeHeight="80dp" + android:targetCellWidth="2" + android:targetCellHeight="2" + android:maxResizeWidth="530dp" + android:maxResizeHeight="530dp" + android:previewLayout="@layout/widget_photo" android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0" android:widgetCategory="home_screen" + android:widgetFeatures="reconfigurable" android:configure="com.nextcloud.client.widget.photo.PhotoWidgetConfigActivity" /> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7112ba815312..a66736a36796 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ orchestratorVersion = "1.6.1" orgJbundleUtilOsgiWrappedOrgApacheHttpClientVersion = "4.1.2" osmdroidAndroidVersion = "6.1.20" photoviewVersion = "2.3.0" +paletteVersion = "1.0.0" playServicesBaseVersion = "18.10.0" prismVersion = "2.0.0" qrcodescannerVersion = "0.1.2.4" @@ -239,6 +240,7 @@ qrcodescanner = { module = "com.github.nextcloud-deps:qrcodescanner", version.re # Worker work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } +palette = { module = "androidx.palette:palette", version.ref = "paletteVersion" } foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } [bundles] diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 33addc56828f..a4b4dad71466 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -16,6 +16,7 @@ + From 9daf46e33d9933cb7fff4d19c13c7befb3df09be Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 16:03:50 +0700 Subject: [PATCH 17/20] feat: Add PhotoWidgetRepository to manage photo widget configurations and retrieve random images. --- .../nextcloud/client/widget/photo/PhotoWidgetRepository.kt | 7 ++----- settings.gradle.kts | 3 +++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index c1ebe94e4d50..7e6eed1852dc 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -232,9 +232,7 @@ class PhotoWidgetRepository @Inject constructor( val existingIds = allFiles.map { it.remoteId }.toSet() val staleIds = history.filter { !existingIds.contains(it) } for (staleId in staleIds) { - ThumbnailsCacheManager.removeBitmapFromDiskCache("r$staleId") - ThumbnailsCacheManager.removeBitmapFromDiskCache("t$staleId") - Log_OC.d(TAG, "Evicted stale cache entry: $staleId") + Log_OC.d(TAG, "Stale cache entry detected (will be evicted by LRU): $staleId") } // Update history to remove stale entries @@ -312,8 +310,7 @@ class PhotoWidgetRepository @Inject constructor( while (history.size >= MAX_VIDEO_CACHE) { val oldId = history.removeAt(0) - ThumbnailsCacheManager.removeBitmapFromDiskCache("video_$oldId") - Log_OC.d(TAG, "Evicted video thumbnail: $oldId") + Log_OC.d(TAG, "Video thumbnail evicted from tracking: $oldId") } preferences.edit().putString(videoHistoryKey, history.joinToString(",")).apply() diff --git a/settings.gradle.kts b/settings.gradle.kts index 3583331170f3..83c75575d52e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,9 @@ pluginManagement { mavenCentral() } } +// plugins { +// id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +// } @Suppress("UnstableApiUsage") dependencyResolutionManagement { From b10dc01f400ba102468faffaa21f0eae1d8cd05f Mon Sep 17 00:00:00 2001 From: szyxxx Date: Tue, 17 Feb 2026 16:59:58 +0700 Subject: [PATCH 18/20] feat: introduce photo widget with configuration and background updates --- .../client/jobs/BackgroundJobManagerImpl.kt | 16 +- .../client/widget/photo/PhotoWidgetConfig.kt | 2 +- .../widget/photo/PhotoWidgetConfigActivity.kt | 151 +++++++++------ .../widget/photo/PhotoWidgetRepository.kt | 40 ++-- .../client/widget/photo/PhotoWidgetWorker.kt | 31 ++- .../layout/activity_photo_widget_config.xml | 182 ++++++++++++++++++ app/src/main/res/layout/widget_photo.xml | 28 ++- app/src/main/res/values/strings.xml | 14 +- 8 files changed, 356 insertions(+), 108 deletions(-) create mode 100644 app/src/main/res/layout/activity_photo_widget_config.xml diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 5f91bd08c06b..a6b5183859c3 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -826,11 +826,15 @@ internal class BackgroundJobManagerImpl( // --------------- Photo Widget --------------- override fun schedulePeriodicPhotoWidgetUpdate(intervalMinutes: Long) { - // Manual mode: cancel any existing periodic work - if (intervalMinutes <= 0L) { - cancelPeriodicPhotoWidgetUpdate() - return - } + // We now ignore the specific intervalMinutes passed here (which is per-widget) + // and always schedule the global worker at the minimum 15-minute interval. + // The worker itself will check each widget's individual interval preference. + + // However, if intervalMinutes is <= 0 (manual), we might want to cancel? + // BUT, since this is now a global job for ALL widgets, we should only cancel if NO widgets want updates. + // For simplicity in this refactor, we'll just schedule it. If all widgets are manual, the worker will just do nothing 99% of the time. + // Or better: The ConfigActivity calls this. + // We should just enforce 15m here. val constraints = Constraints.Builder() .build() @@ -838,7 +842,7 @@ internal class BackgroundJobManagerImpl( val request = periodicRequestBuilder( jobClass = com.nextcloud.client.widget.photo.PhotoWidgetWorker::class, jobName = JOB_PERIODIC_PHOTO_WIDGET, - intervalMins = intervalMinutes + intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES ) .setConstraints(constraints) .setBackoffCriteria( diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt index 165630b00cb5..de67f280f103 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfig.kt @@ -19,6 +19,6 @@ data class PhotoWidgetConfig( ) { companion object { const val DEFAULT_INTERVAL_MINUTES = 15L - val INTERVAL_OPTIONS = longArrayOf(5L, 15L, 30L, 60L, 0L) // 0 = manual + val INTERVAL_OPTIONS = longArrayOf(15L, 30L, 60L, 0L) // 0 = manual } } diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt index 200055343b3b..fc22f1bf371b 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt @@ -7,11 +7,13 @@ package com.nextcloud.client.widget.photo import android.app.Activity -import android.app.AlertDialog import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Build import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import com.google.android.material.chip.ChipGroup import com.nextcloud.client.account.UserAccountManager import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -23,8 +25,7 @@ import javax.inject.Inject * Configuration activity launched when the user places a Photo Widget * or reconfigures an existing one (Android 12+). * - * Opens [FolderPickerActivity] for folder selection, then shows an interval - * picker dialog, saves the config, and triggers an immediate widget update. + * Uses a modern bottom-sheet style UI (ConstraintLayout) for configuration. */ @Suppress("TooManyFunctions") class PhotoWidgetConfigActivity : Activity() { @@ -41,9 +42,16 @@ class PhotoWidgetConfigActivity : Activity() { private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + // UI Elements + private lateinit var folderPathText: TextView + private lateinit var folderChangeBtn: Button + private lateinit var intervalChipGroup: ChipGroup + private lateinit var addWidgetBtn: Button + override fun onCreate(savedInstanceState: Bundle?) { AndroidInjection.inject(this) super.onCreate(savedInstanceState) + setContentView(R.layout.activity_photo_widget_config) // Set result to CANCELED in case the user backs out setResult(RESULT_CANCELED) @@ -61,12 +69,68 @@ class PhotoWidgetConfigActivity : Activity() { viewModel.setWidgetId(appWidgetId) - // Launch FolderPickerActivity for folder selection - val folderPickerIntent = Intent(this, FolderPickerActivity::class.java).apply { - putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + bindViews() + setupListeners() + restoreState() + } + + private fun bindViews() { + folderPathText = findViewById(R.id.folder_path) + folderChangeBtn = findViewById(R.id.folder_change_btn) + intervalChipGroup = findViewById(R.id.interval_chip_group) + addWidgetBtn = findViewById(R.id.add_widget_btn) + } + + private fun setupListeners() { + folderChangeBtn.setOnClickListener { + val intent = Intent(this, FolderPickerActivity::class.java).apply { + putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + } + @Suppress("DEPRECATION") + startActivityForResult(intent, REQUEST_FOLDER_PICKER) + } + + intervalChipGroup.setOnCheckedChangeListener { _, _ -> + checkValidation() + } + + addWidgetBtn.setOnClickListener { + finishConfiguration() } - @Suppress("DEPRECATION") - startActivityForResult(folderPickerIntent, REQUEST_FOLDER_PICKER) + } + + private fun restoreState() { + val existingConfig = viewModel.getExistingConfig() + + // Restore Folder + if (existingConfig != null) { + // We only describe the path here since we don't have the full OCFile object yet without querying. + // But we can simulate selection if needed, or just set the text. + // For robust editing, ideally we would fetch the OCFile, but standard flow usually picks new. + // For now, let's just trigger picker if it's a new widget. + folderPathText.text = existingConfig.folderPath + addWidgetBtn.text = getString(R.string.common_save) // "Save" vs "Add" + + // Restore Interval + val interval = existingConfig.intervalMinutes + val chipId = when (interval) { + 15L -> R.id.chip_15m + 30L -> R.id.chip_30m + 60L -> R.id.chip_1h + 0L -> R.id.chip_manual + else -> R.id.chip_15m // Default fallback + } + intervalChipGroup.check(chipId) + } else { + // New Widget Default state + folderPathText.text = getString(R.string.photo_widget_select_folder) + intervalChipGroup.check(R.id.chip_15m) // Default 15m + + // Immediately launch picker for better UX on fresh add + folderChangeBtn.performClick() + } + + checkValidation() } @Suppress("DEPRECATION") @@ -77,67 +141,42 @@ class PhotoWidgetConfigActivity : Activity() { val folder: OCFile? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER, OCFile::class.java) } else { - @Suppress("DEPRECATION") data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER) } if (folder != null) { viewModel.setSelectedFolder(folder) - showIntervalPicker(folder) - } else { - finish() + folderPathText.text = folder.remotePath + checkValidation() } - } else { + } else if (requestCode == REQUEST_FOLDER_PICKER && viewModel.getSelectedFolder() == null && viewModel.getExistingConfig() == null) { + // If user cancelled picker on first launch AND no existing config, finish activity finish() } } - /** - * Shows a dialog for the user to pick a refresh interval. - * Presets: 5 / 15 / 30 / 60 minutes / Manual only. - */ - private fun showIntervalPicker(selectedFolder: OCFile) { - val labels = arrayOf( - getString(R.string.photo_widget_interval_5), - getString(R.string.photo_widget_interval_15), - getString(R.string.photo_widget_interval_30), - getString(R.string.photo_widget_interval_60), - getString(R.string.photo_widget_interval_manual) - ) - val values = PhotoWidgetConfig.INTERVAL_OPTIONS // [5, 15, 30, 60, 0] - - // Default selection: 15 minutes (index 1) - var selectedIndex = 1 - - // If reconfiguring, use existing interval as default - val existingConfig = viewModel.getExistingConfig() - if (existingConfig != null) { - val existingIndex = values.indexOf(existingConfig.intervalMinutes) - if (existingIndex >= 0) { - selectedIndex = existingIndex - } + private fun checkValidation() { + val hasFolder = viewModel.getSelectedFolder() != null || viewModel.getExistingConfig() != null + val hasInterval = intervalChipGroup.checkedChipId != -1 + addWidgetBtn.isEnabled = hasFolder && hasInterval + } + + private fun finishConfiguration() { + // Resolve interval + val intervalMinutes = when (intervalChipGroup.checkedChipId) { + R.id.chip_15m -> 15L + R.id.chip_30m -> 30L + R.id.chip_1h -> 60L + R.id.chip_manual -> 0L + else -> 15L } - AlertDialog.Builder(this) - .setTitle(getString(R.string.photo_widget_interval_title)) - .setSingleChoiceItems(labels, selectedIndex) { _, which -> - selectedIndex = which - } - .setPositiveButton(android.R.string.ok) { _, _ -> - val intervalMinutes = values[selectedIndex] - finishConfiguration(selectedFolder, intervalMinutes) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - finish() - } - .setOnCancelListener { - finish() - } - .show() - } + // Resolve folder path (prefer new selection, fallback to existing config) + val selectedFolder = viewModel.getSelectedFolder() + val folderPath = selectedFolder?.remotePath ?: viewModel.getExistingConfig()?.folderPath + + if (folderPath == null) return - private fun finishConfiguration(folder: OCFile, intervalMinutes: Long) { - val folderPath = folder.remotePath val accountName = userAccountManager.user.accountName // Delegate all business logic to ViewModel diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt index 7e6eed1852dc..851f65367940 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt @@ -73,8 +73,6 @@ class PhotoWidgetRepository @Inject constructor( private const val CACHE_HISTORY_LIMIT = 10 private const val OFFLINE_BIAS_PERCENT = 80 private const val OFFLINE_FALLBACK_ATTEMPTS = 3 - private const val RECENT_FILES_COUNT = 20 - private const val RANDOM_FILES_COUNT = 10 private const val MAX_VIDEO_CACHE = 2 private const val PLAY_ICON_SIZE_RATIO = 0.15f } @@ -104,6 +102,14 @@ class PhotoWidgetRepository @Inject constructor( .apply() } + fun setWidgetLastUpdateTimestamp(widgetId: Int, timestamp: Long) { + preferences.edit().putLong("${PREF_PREFIX}last_update_$widgetId", timestamp).apply() + } + + fun getWidgetLastUpdateTimestamp(widgetId: Int): Long { + return preferences.getLong("${PREF_PREFIX}last_update_$widgetId", 0L) + } + // --------------- Image retrieval --------------- /** @@ -116,7 +122,7 @@ class PhotoWidgetRepository @Inject constructor( /** * Returns a random image result with bitmap and metadata, or `null` on failure. * - * Uses a "Smart Mix" strategy combining On This Day, Recent, and Random files. + * Picks a truly random photo from ALL available media files (old, recent, any era). * Supports both image and video files (videos show as thumbnail + ▶ overlay). * Pre-fetches the next candidate for instant loading. */ @@ -134,20 +140,8 @@ class PhotoWidgetRepository @Inject constructor( // Cache invalidation: if file count changed, clear stale cache invalidateCacheIfNeeded(widgetId, allFiles) - // IMPLEMENTATION OF "SMART MIX" STRATEGY - // 1. "On This Day": Photos from today's date in past years - val onThisDayFiles = allFiles.filter { isOnThisDay(it.modificationTimestamp) } - - // 2. "Recent": Top 20 newest photos - val recentFiles = allFiles.sortedByDescending { it.modificationTimestamp }.take(RECENT_FILES_COUNT) - - // 3. "Random": 10 random files from the rest to add variety - val usedIds = (onThisDayFiles + recentFiles).map { it.remoteId }.toSet() - val remainingFiles = allFiles.filter { !usedIds.contains(it.remoteId) } - val randomFiles = remainingFiles.shuffled().take(RANDOM_FILES_COUNT) - - // Combine all candidates — include both images and videos - val candidatePool = (onThisDayFiles + recentFiles + randomFiles).filter { isMediaFile(it) } + // Truly random selection from ALL media files + val candidatePool = allFiles.filter { isMediaFile(it) } if (candidatePool.isEmpty()) { return null @@ -352,18 +346,6 @@ class PhotoWidgetRepository @Inject constructor( // --------------- Smart Mix helpers --------------- - private fun isOnThisDay(timestamp: Long): Boolean { - val calendar = java.util.Calendar.getInstance() - val todayMonth = calendar.get(java.util.Calendar.MONTH) - val todayDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) - - calendar.timeInMillis = timestamp - val fileMonth = calendar.get(java.util.Calendar.MONTH) - val fileDay = calendar.get(java.util.Calendar.DAY_OF_MONTH) - - return (todayMonth == fileMonth && todayDay == fileDay) - } - private fun manageWidgetCache(widgetId: Int, newFile: OCFile, allFiles: List) { val prefKey = "${PREF_PREFIX}history_$widgetId" val historyString = preferences.getString(prefKey, "") ?: "" diff --git a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt index 286c97076b28..4663cae20d99 100644 --- a/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt +++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt @@ -47,9 +47,31 @@ class PhotoWidgetWorker( val appWidgetManager = AppWidgetManager.getInstance(context) val componentName = ComponentName(context, PhotoWidgetProvider::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + val isImmediate = tags.contains("immediate_photo_widget") for (widgetId in widgetIds) { - updateWidget(appWidgetManager, widgetId) + // Get config for this widget + val config = photoWidgetRepository.getWidgetConfig(widgetId) ?: continue + + // Determine if we should update based on interval + val shouldUpdate = if (isImmediate) { + true + } else { + val intervalMinutes = config.intervalMinutes + if (intervalMinutes <= 0) { + false // Manual mode, don't auto-update + } else { + val lastUpdate = photoWidgetRepository.getWidgetLastUpdateTimestamp(widgetId) + val intervalMillis = intervalMinutes * 60 * 1000 + (System.currentTimeMillis() - lastUpdate) >= intervalMillis + } + } + + if (shouldUpdate) { + updateWidget(appWidgetManager, widgetId) + // Save timestamp + photoWidgetRepository.setWidgetLastUpdateTimestamp(widgetId, System.currentTimeMillis()) + } } return Result.success() @@ -90,6 +112,10 @@ class PhotoWidgetWorker( remoteViews.setViewVisibility(R.id.photo_widget_empty_state, android.view.View.VISIBLE) remoteViews.setViewVisibility(R.id.photo_widget_scrim, android.view.View.GONE) remoteViews.setViewVisibility(R.id.photo_widget_text_container, android.view.View.GONE) + + // Apply static tints programmatically (XML android:tint crashes on some RemoteViews) + remoteViews.setInt(R.id.photo_widget_empty_icon, "setColorFilter", android.graphics.Color.parseColor("#666680")) + remoteViews.setInt(R.id.photo_widget_retry_button, "setColorFilter", android.graphics.Color.parseColor("#AAAACC")) } // Set click on photo to open the specific file in FileDisplayActivity @@ -118,6 +144,9 @@ class PhotoWidgetWorker( remoteViews.setViewVisibility(R.id.photo_widget_next_button, android.view.View.VISIBLE) remoteViews.setOnClickPendingIntent(R.id.photo_widget_next_button, nextPendingIntent) + // Wire retry button in empty state to same refresh action + remoteViews.setOnClickPendingIntent(R.id.photo_widget_retry_button, nextPendingIntent) + appWidgetManager.updateAppWidget(widgetId, remoteViews) } diff --git a/app/src/main/res/layout/activity_photo_widget_config.xml b/app/src/main/res/layout/activity_photo_widget_config.xml new file mode 100644 index 000000000000..924e1ac642b9 --- /dev/null +++ b/app/src/main/res/layout/activity_photo_widget_config.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + +