Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6da72df
feat: implement a new photo widget to display Nextcloud photos.
szyxxx Feb 16, 2026
33ec730
feat: implement photo widget functionality including background job m…
szyxxx Feb 16, 2026
14f7e7c
feat: Implement a new photo widget with configuration, background upd…
szyxxx Feb 16, 2026
c212802
fix: address PR review and Codacy static analysis issues
szyxxx Feb 16, 2026
8832393
fix: add date validity check and use thread-safe DateTimeFormatter
szyxxx Feb 16, 2026
542ad66
ci: add auto-sync upstream and auto-build release workflows
szyxxx Feb 16, 2026
e95f327
feat: implement photo widget worker and its layout to display random …
szyxxx Feb 17, 2026
77acabc
feat: implement a new photo widget to display Nextcloud photos.
szyxxx Feb 16, 2026
c272673
feat: implement photo widget functionality including background job m…
szyxxx Feb 16, 2026
1ff3760
feat: Implement a new photo widget with configuration, background upd…
szyxxx Feb 16, 2026
fb7dd45
fix: address PR review and Codacy static analysis issues
szyxxx Feb 16, 2026
fc29538
fix: add date validity check and use thread-safe DateTimeFormatter
szyxxx Feb 16, 2026
b71cf9e
ci: add auto-sync upstream and auto-build release workflows
szyxxx Feb 16, 2026
8daed6f
feat: implement photo widget worker and its layout to display random …
szyxxx Feb 17, 2026
af5807b
Merge branch 'master' of https://github.com/szyxxx/nextcloud-photos-w…
szyxxx Feb 17, 2026
f54d752
fix(ci): rewrite build workflow - unique tags, fix gradle config, use…
szyxxx Feb 17, 2026
ff5b767
feat: Add a new photo widget with configurable image and video display.
szyxxx Feb 17, 2026
9daf46e
feat: Add PhotoWidgetRepository to manage photo widget configurations…
szyxxx Feb 17, 2026
b10dc01
feat: introduce photo widget with configuration and background updates
szyxxx Feb 17, 2026
dee4a22
feat: Initial setup of Android application build with Gradle Kotlin D…
szyxxx Feb 17, 2026
1bd9a36
feat: implement PhotoWidgetWorker to fetch and display random photos,…
szyxxx Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
73 changes: 73 additions & 0 deletions .github/workflows/sync-upstream.yml
Original file line number Diff line number Diff line change
@@ -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']
});
}
13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "\"\"")
Expand Down Expand Up @@ -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)
Expand Down
Binary file added app/debug.keystore
Binary file not shown.
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,26 @@
android:resource="@xml/dashboard_widget_info" />
</receiver>

<receiver
android:name="com.nextcloud.client.widget.photo.PhotoWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/photo_widget_info" />
</receiver>

<activity
android:name="com.nextcloud.client.widget.photo.PhotoWidgetConfigActivity"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<activity
android:name=".ui.activity.UploadFilesActivity"
android:configChanges="orientation|screenSize"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity;
import com.nextcloud.client.widget.DashboardWidgetProvider;
import com.nextcloud.client.widget.DashboardWidgetService;
import com.nextcloud.client.widget.photo.PhotoWidgetConfigActivity;
import com.nextcloud.client.widget.photo.PhotoWidgetProvider;
import com.nextcloud.receiver.NetworkChangeReceiver;
import com.nextcloud.ui.ChooseAccountDialogFragment;
import com.nextcloud.ui.ChooseStorageLocationDialogFragment;
Expand Down Expand Up @@ -454,6 +456,12 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract DashboardWidgetProvider dashboardWidgetProvider();

@ContributesAndroidInjector
abstract PhotoWidgetProvider photoWidgetProvider();

@ContributesAndroidInjector
abstract PhotoWidgetConfigActivity photoWidgetConfigActivity();

@ContributesAndroidInjector
abstract GalleryFragmentBottomSheetDialog galleryFragmentBottomSheetDialog();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ class BackgroundJobFactory @Inject constructor(
private val localBroadcastManager: Provider<LocalBroadcastManager>,
private val generatePdfUseCase: GeneratePDFUseCase,
private val syncedFolderProvider: SyncedFolderProvider,
private val database: NextcloudDatabase
private val database: NextcloudDatabase,
private val photoWidgetRepository: Provider<com.nextcloud.client.widget.photo.PhotoWidgetRepository>
) : WorkerFactory() {

@SuppressLint("NewApi")
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
)
}
Comment on lines 828 to 860
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

The schedulePeriodicPhotoWidgetUpdate method requires network connectivity (line 837), but the PhotoWidgetRepository is designed to support offline fallback by using cached images. When users enable the widget and select a manual-only refresh interval, they may still expect cached images to rotate. Consider removing the network constraint from periodic updates or making it optional, as the repository can successfully serve cached images offline.

Copilot uses AI. Check for mistakes.

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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading