diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
new file mode 100644
index 000000000000..d88887edfd06
--- /dev/null
+++ b/.github/workflows/build-release.yml
@@ -0,0 +1,76 @@
+# 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: |
+ echo "org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" >> 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 Debug APK
+ run: ./gradlew assembleGenericDebug
+
+ - name: Prepare release info
+ id: info
+ run: |
+ 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: Rename APK
+ run: |
+ 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 Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.info.outputs.tag }}
+ name: "Photo Widget — ${{ steps.info.outputs.date }}"
+ body: |
+ 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.rename.outputs.final_apk }}
+ 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']
+ });
+ }
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 248f60acfc60..69582f2c4576 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -123,6 +123,15 @@ android {
flavorDimensions += "default"
+ signingConfigs {
+ getByName("debug") {
+ storeFile = file("debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ }
+
buildTypes {
release {
buildConfigField("String", "NC_TEST_SERVER_DATA_STRING", "\"\"")
@@ -377,6 +386,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/debug.keystore b/app/debug.keystore
new file mode 100644
index 000000000000..5c7e5b311554
Binary files /dev/null and b/app/debug.keystore differ
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..a6b5183859c3 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,8 @@ 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 JOB_TEST = "test_job"
@@ -820,4 +822,63 @@ internal class BackgroundJobManagerImpl(
override fun cancelFolderDownload() {
workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER)
}
+
+ // --------------- Photo Widget ---------------
+
+ override fun schedulePeriodicPhotoWidgetUpdate(intervalMinutes: Long) {
+ // 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()
+
+ val request = periodicRequestBuilder(
+ jobClass = com.nextcloud.client.widget.photo.PhotoWidgetWorker::class,
+ jobName = JOB_PERIODIC_PHOTO_WIDGET,
+ intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
+ )
+ .setConstraints(constraints)
+ .setBackoffCriteria(
+ androidx.work.BackoffPolicy.EXPONENTIAL,
+ 30L,
+ java.util.concurrent.TimeUnit.SECONDS
+ )
+ .build()
+
+ workManager.enqueueUniquePeriodicWork(
+ JOB_PERIODIC_PHOTO_WIDGET,
+ ExistingPeriodicWorkPolicy.REPLACE,
+ request
+ )
+ }
+
+ override fun startImmediatePhotoWidgetUpdate() {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
+ .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..de67f280f103
--- /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(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..fc22f1bf371b
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetConfigActivity.kt
@@ -0,0 +1,192 @@
+/*
+ * 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.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
+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
+ * or reconfigures an existing one (Android 12+).
+ *
+ * Uses a modern bottom-sheet style UI (ConstraintLayout) for configuration.
+ */
+@Suppress("TooManyFunctions")
+class PhotoWidgetConfigActivity : Activity() {
+
+ companion object {
+ private const val REQUEST_FOLDER_PICKER = 1001
+ }
+
+ @Inject
+ lateinit var viewModel: PhotoWidgetConfigViewModel
+
+ @Inject
+ lateinit var userAccountManager: UserAccountManager
+
+ 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)
+
+ // 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
+ }
+
+ viewModel.setWidgetId(appWidgetId)
+
+ 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()
+ }
+ }
+
+ 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")
+ 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 {
+ data.getParcelableExtra(FolderPickerActivity.EXTRA_FOLDER)
+ }
+
+ if (folder != null) {
+ viewModel.setSelectedFolder(folder)
+ folderPathText.text = folder.remotePath
+ checkValidation()
+ }
+ } 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()
+ }
+ }
+
+ 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
+ }
+
+ // 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
+
+ val accountName = userAccountManager.user.accountName
+
+ // Delegate all business logic to ViewModel
+ viewModel.saveConfiguration(folderPath, accountName, intervalMinutes)
+
+ // 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/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/PhotoWidgetProvider.kt b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt
new file mode 100644
index 000000000000..a726f2f7518c
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetProvider.kt
@@ -0,0 +1,83 @@
+/*
+ * 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].
+ */
+@Suppress("TooManyFunctions")
+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) {
+ 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
+ }
+
+ super.onReceive(context, intent)
+ }
+
+ 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
new file mode 100644
index 000000000000..851f65367940
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetRepository.kt
@@ -0,0 +1,488 @@
+/*
+ * 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 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
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+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,
+ val isVideo: Boolean = false,
+ val fileRemotePath: String? = null,
+ val fileId: Long? = null
+)
+
+/**
+ * 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
+ * - Pre-fetch the next candidate for instant loading
+ * - Extract video thumbnails with play icon overlay
+ */
+@Suppress("MagicNumber", "TooManyFunctions")
+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 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 MAX_VIDEO_CACHE = 2
+ private const val PLAY_ICON_SIZE_RATIO = 0.15f
+ }
+
+ 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(config: PhotoWidgetConfig) {
+ preferences.edit()
+ .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()
+ }
+
+ fun deleteWidgetConfig(widgetId: Int) {
+ preferences.edit()
+ .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()
+ }
+
+ 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 ---------------
+
+ /**
+ * 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.
+ *
+ * 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.
+ */
+ @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
+ fun getRandomImageResult(widgetId: Int): PhotoWidgetImageResult? {
+ val config = getWidgetConfig(widgetId) ?: 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(folderPath) ?: return null
+ val allFiles = storageManager.getAllFilesRecursivelyInsideFolder(folder)
+
+ // Cache invalidation: if file count changed, clear stale cache
+ invalidateCacheIfNeeded(widgetId, allFiles)
+
+ // Truly random selection from ALL media files
+ val candidatePool = allFiles.filter { isMediaFile(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
+ var candidateFile = if (offlineFiles.isNotEmpty() && (percentage(OFFLINE_BIAS_PERCENT) || onlineFiles.isEmpty())) {
+ offlineFiles.random()
+ } else if (onlineFiles.isNotEmpty()) {
+ onlineFiles.random()
+ } else {
+ candidatePool.random()
+ }
+
+ val isVideo = isVideoFile(candidateFile)
+ var bitmap = if (isVideo) {
+ getVideoThumbnail(candidateFile)
+ } else {
+ getThumbnailForFile(candidateFile, accountName)
+ }
+
+ // FAILSAFE: If the selected candidate failed, try offline fallback
+ if (bitmap == null && offlineFiles.isNotEmpty()) {
+ 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) {
+ candidateFile = fallback
+ break
+ }
+ }
+ }
+
+ if (bitmap == null) {
+ Log_OC.e(TAG, "Failed to load any widget image")
+ 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,
+ 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) {
+ Log_OC.d(TAG, "Stale cache entry detected (will be evicted by LRU): $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)
+ Log_OC.d(TAG, "Video thumbnail evicted from tracking: $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 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
+ while (history.size > CACHE_HISTORY_LIMIT) {
+ val oldId = history.removeAt(0)
+ 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")
+ }
+ }
+
+ preferences.edit().putString(prefKey, history.joinToString(",")).apply()
+ }
+
+ private fun percentage(chance: Int): Boolean {
+ return (Math.random() * 100).toInt() < chance
+ }
+
+ 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("video/")
+ }
+
+ // --------------- Thumbnail retrieval ---------------
+
+ /**
+ * 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 (Best Quality)
+ val imageKey = "r" + file.remoteId
+ var bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey)
+ if (bitmap != null) return scaleBitmap(bitmap)
+
+ // 2. Try "thumbnail" cache key (Fallback)
+ val thumbnailKey = "t" + file.remoteId
+ bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(thumbnailKey)
+ if (bitmap != null) return scaleBitmap(bitmap)
+
+ // 3. If missing, generate from local file
+ if (file.isDown) {
+ bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, SERVER_REQUEST_DIMENSION, SERVER_REQUEST_DIMENSION)
+ if (bitmap != null) {
+ val keyToCache = imageKey
+ ThumbnailsCacheManager.addBitmapToCache(keyToCache, bitmap)
+ return scaleBitmap(bitmap)
+ }
+ }
+
+ // 4. Download from server (High Res)
+ 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()
+ .getClientFor(user.toOwnCloudAccount(), MainApp.getAppContext())
+
+ 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"
+
+ Log_OC.d(TAG, "Downloading widget high-res preview: $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()
+ }
+
+ 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
new file mode 100644
index 000000000000..34d761cbac6e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/widget/photo/PhotoWidgetWorker.kt
@@ -0,0 +1,267 @@
+/*
+ * 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.palette.graphics.Palette
+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.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Background worker that fetches a random photo and updates all photo widgets.
+ *
+ * Constructed by [com.nextcloud.client.jobs.BackgroundJobFactory].
+ */
+@Suppress("TooManyFunctions")
+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 NEXT_BUTTON_REQUEST_CODE_OFFSET = 10000
+ private const val BRIGHTNESS_THRESHOLD = 128
+ }
+
+ override suspend fun doWork(): Result {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val componentName = ComponentName(context, PhotoWidgetProvider::class.java)
+ val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
+ val isImmediate = tags.any { it.contains("immediate_photo_widget") }
+
+ for (widgetId in widgetIds) {
+ // 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()
+ }
+
+ @Suppress("LongMethod")
+ private suspend fun updateWidget(appWidgetManager: AppWidgetManager, widgetId: Int) {
+ val remoteViews = RemoteViews(context.packageName, R.layout.widget_photo)
+
+ 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 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)
+ 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)
+ // 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 {
+ // 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)
+
+ // 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
+ val config = photoWidgetRepository.getWidgetConfig(widgetId)
+ val clickIntent = createOpenFileIntent(config, imageResult)
+ 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
+ )
+ // 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)
+
+ // Wire retry button in empty state to same refresh action
+ remoteViews.setOnClickPendingIntent(R.id.photo_widget_retry_button, nextPendingIntent)
+
+ 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.
+ */
+ @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")
+ 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
+ }
+
+ 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()) {
+ val address = addresses[0]
+ // 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
+ country != null -> country
+ 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
+ }
+ }
+
+ /**
+ * 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_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/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/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/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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..12ec1e3009dc
--- /dev/null
+++ b/app/src/main/res/layout/widget_photo.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dee81fb3f3e0..325a7a64ee3b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1354,6 +1354,23 @@
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
+ Configure Widget
+ Choose a folder and refresh interval
+ Source folder
+ Add Widget
+ Refresh interval
+ 15m
+ 30m
+ 1h
+ Manual
+ Tap refresh to load photos
Icon for empty list
No items
Check back later or reload.
@@ -1384,6 +1401,7 @@
Choose export type
PDF file
Multiple images
+ Change
Cannot create local file
Invalid filename for local file
Groupfolders
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..eb5c7fa73cb7
--- /dev/null
+++ b/app/src/main/res/xml/photo_widget_info.xml
@@ -0,0 +1,25 @@
+
+
+
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..ba210f28d3ae
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/client/widget/photo/PhotoWidgetRepositoryTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.putLong(anyString(), org.mockito.ArgumentMatchers.anyLong())).thenReturn(editor)
+ whenever(editor.remove(anyString())).thenReturn(editor)
+ repository = PhotoWidgetRepository(preferences, userAccountManager, contentResolver)
+ }
+
+ @Test
+ fun `saveWidgetConfig stores folder path account name and interval`() {
+ 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()
+ }
+
+ @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)
+ }
+}
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 @@
+
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 {