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 @@ + + + + + + + + + + + + + + + + + + + +