diff --git a/android/app/build.gradle b/android/app/build.gradle index 19f423e7e..ad3b8b6b3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,5 @@ plugins { id 'com.android.application' - id 'kotlin-android' id 'com.google.devtools.ksp' version '2.3.4' } @@ -59,4 +58,9 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:5.3.2" implementation "io.github.g00fy2:versioncompare:1.5.0" + + def work_version = "2.9.1" + implementation "androidx.work:work-runtime-ktx:$work_version" + + implementation 'com.github.bumptech.glide:glide:4.16.0' } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a73ba1616..2aeff30c7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ @@ -38,6 +39,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/MainActivity.kt b/android/app/src/main/java/gallery/memories/MainActivity.kt index 09b2195fa..7623ebc14 100644 --- a/android/app/src/main/java/gallery/memories/MainActivity.kt +++ b/android/app/src/main/java/gallery/memories/MainActivity.kt @@ -131,6 +131,9 @@ class MainActivity : AppCompatActivity() { // Load JavaScript initializeWebView() + // Handle widget deep link (open specific photo) + handleWidgetDeepLink(intent) + // Destroy video after 1 seconds (workaround for video not showing on first load) binding.videoView.postDelayed({ binding.videoView.alpha = 1.0f @@ -338,6 +341,44 @@ class MainActivity : AppCompatActivity() { return false } + /** + * Handle a widget deep link: if the intent has a photo subpath, + * navigate the webview to show that specific photo. + */ + private fun handleWidgetDeepLink(intent: Intent?) { + if (intent == null) return + + // Server photo: navigate webview to the photo viewer + val subpath = intent.getStringExtra("gallery.memories.widget.EXTRA_PHOTO_SUBPATH") + if (!subpath.isNullOrBlank() && host != null) { + binding.webview.postDelayed({ + nativex.http.loadWebView(binding.webview, subpath) + }, 1000) // delay to let the initial page load + return + } + + // Local photo: open with system viewer + val localUri = intent.getStringExtra("gallery.memories.widget.EXTRA_LOCAL_PHOTO_URI") + if (!localUri.isNullOrBlank()) { + try { + val uri = Uri.parse(localUri) + val viewIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(viewIntent) + } catch (e: Exception) { + Log.w(TAG, "Failed to open local photo from widget", e) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleWidgetDeepLink(intent) + } + fun initializePlayer(uris: Array, uid: Long) { if (player != null) { if (playerUid == uid) return diff --git a/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt b/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt index 092b617d3..307b6b5fe 100644 --- a/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt +++ b/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt @@ -12,9 +12,24 @@ interface PhotoDao { @Query("SELECT 1") fun ping(): Int + @Query("SELECT COUNT(*) FROM photos") + fun getCount(): Int + @Query("SELECT dayid, COUNT(local_id) AS count FROM photos WHERE bucket_id IN (:bucketIds) AND has_remote = 0 GROUP BY dayid ORDER BY dayid DESC") fun getDays(bucketIds: List): List + @Query("SELECT * FROM photos WHERE bucket_id IN (:bucketIds) ORDER BY RANDOM() LIMIT 1") + fun getRandomPhoto(bucketIds: List): Photo? + + @Query("SELECT * FROM photos WHERE strftime('%m-%d', date_taken, 'unixepoch') = :date AND bucket_id IN (:bucketIds) ORDER BY date_taken DESC") + fun getOnThisDayPhotos(date: String, bucketIds: List): List + + @Query("SELECT * FROM photos ORDER BY RANDOM() LIMIT 1") + fun getRandomPhotoAny(): Photo? + + @Query("SELECT * FROM photos WHERE strftime('%m-%d', date_taken, 'unixepoch') = :date ORDER BY date_taken DESC") + fun getOnThisDayPhotosAny(date: String): List + @Query("SELECT * FROM photos WHERE dayid=:dayId AND bucket_id IN (:buckets) AND has_remote = 0 ORDER BY date_taken DESC") fun getPhotosByDay(dayId: Long, buckets: List): List diff --git a/android/app/src/main/java/gallery/memories/widget/CacheRefreshWorker.kt b/android/app/src/main/java/gallery/memories/widget/CacheRefreshWorker.kt new file mode 100644 index 000000000..e6f4d29f9 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/widget/CacheRefreshWorker.kt @@ -0,0 +1,281 @@ +package gallery.memories.widget + +import SecureStorage +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import kotlin.random.Random + +/** + * Periodic worker that pre-fetches a batch of photos into the widget cache every 5 minutes. + * This ensures the cache always has fresh, random photos so manual refreshes feel instant and varied. + */ +@OptIn(UnstableApi::class) +class CacheRefreshWorker( + private val context: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + return try { + refreshCache() + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Cache refresh failed", e) + Result.success() // Don't retry — will run again on next period + } + } + + private fun refreshCache() { + val store = SecureStorage(context) + val cred = store.getCredentials() ?: run { + Log.d(TAG, "No credentials — skipping cache refresh") + return + } + + val client = buildOkHttpClient(cred.trustAll) + val authHeader = "Basic ${Base64.encodeToString( + "${cred.username}:${cred.token}".toByteArray(), Base64.NO_WRAP + )}" + + // Fetch days list + val daysArray = fetchDays(client, cred.url, authHeader) ?: return + if (daysArray.length() == 0) return + + // Fetch BATCH_SIZE random photos + var fetched = 0 + val maxAttempts = BATCH_SIZE * 3 // allow some failures + var attempts = 0 + + while (fetched < BATCH_SIZE && attempts < maxAttempts) { + attempts++ + try { + val (dayId, isOtd) = selectDay(daysArray) + val fileId = fetchRandomPhotoId(client, cred.url, authHeader, dayId) ?: continue + + // Build metadata + val dayDate = LocalDate.ofEpochDay(dayId) + val labelText: String + val dateText: String? + val currentYear = LocalDate.now().year + + if (isOtd) { + labelText = "On this day" + val diff = currentYear - dayDate.year + dateText = when { + diff == 1 -> "1 year ago" + diff > 1 -> "$diff years ago" + else -> null + } + } else { + val labels = arrayOf("From your memories", "Throwback", "Remember this?", "Rediscover") + labelText = labels.random() + dateText = dayDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy")) + } + + // Location + val locationText = fetchServerLocation(client, cred.url, authHeader, fileId) + + // Download preview + val bitmap = downloadPreview(client, cred.url, authHeader, fileId) ?: continue + + val photoUri = "#v/$dayId/$fileId" + cacheImage(bitmap, "batch_$fileId", labelText, dateText, locationText, photoUri) + + fetched++ + Log.d(TAG, "Cached batch photo $fetched/$BATCH_SIZE: fileId=$fileId") + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch batch photo", e) + } + } + + Log.d(TAG, "Cache refresh complete: $fetched photos fetched") + } + + // -- Server helpers (mirrored from WidgetWorker, simplified) ---------- + + private fun fetchDays(client: OkHttpClient, baseUrl: String, authHeader: String): JSONArray? { + return client.newCall(buildApiRequest(baseUrl, "api/days", authHeader)) + .execute().use { response -> + if (response.code != 200) return null + JSONArray(response.body.string()) + } + } + + private fun selectDay(daysArray: JSONArray): Pair { + val today = LocalDate.now() + val todayMd = today.format(DateTimeFormatter.ofPattern("MM-dd")) + + val otd = (0 until daysArray.length()) + .map { daysArray.getJSONObject(it) } + .filter { + val d = LocalDate.ofEpochDay(it.getLong("dayid")) + d.format(DateTimeFormatter.ofPattern("MM-dd")) == todayMd && d.year != today.year + } + + if (otd.isNotEmpty() && Random.nextDouble() < 0.7) { + return otd.random().getLong("dayid") to true + } + + val idx = (0 until daysArray.length()).random() + return daysArray.getJSONObject(idx).getLong("dayid") to false + } + + private fun fetchRandomPhotoId( + client: OkHttpClient, baseUrl: String, authHeader: String, dayId: Long, + ): Long? { + val arr = client.newCall(buildApiRequest(baseUrl, "api/days/$dayId", authHeader)) + .execute().use { response -> + if (response.code != 200) return null + JSONArray(response.body.string()) + } + if (arr.length() == 0) return null + return arr.getJSONObject((0 until arr.length()).random()).getLong("fileid") + } + + private fun fetchServerLocation( + client: OkHttpClient, baseUrl: String, authHeader: String, fileId: Long, + ): String? { + return try { + client.newCall(buildApiRequest(baseUrl, "api/image/info/$fileId", authHeader)) + .execute().use { response -> + if (response.code != 200) return null + val json = JSONObject(response.body.string()) + val raw = json.optString("address", "").ifBlank { null } + raw?.let { simplifyAddress(it) } + } + } catch (e: Exception) { null } + } + + private fun downloadPreview( + client: OkHttpClient, baseUrl: String, authHeader: String, fileId: Long, + ): Bitmap? { + val bytes = client.newCall( + buildApiRequest(baseUrl, "api/image/preview/$fileId?x=1024&y=1024", authHeader) + ).execute().use { response -> + if (response.code != 200) return null + response.body.bytes() + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + + private fun simplifyAddress(address: String): String { + val parts = address.split(",").map { it.trim() }.filter { it.isNotBlank() } + return when { + parts.size <= 2 -> parts.joinToString(", ") + else -> "${parts.first()}, ${parts.last()}" + } + } + + // -- Cache / HTTP helpers --------------------------------------------- + + private fun cacheImage( + bitmap: Bitmap, tag: String, + labelText: String?, dateText: String?, locationText: String?, photoUri: String?, + ) { + val dir = File(context.filesDir, CACHE_DIR).also { if (!it.exists()) it.mkdirs() } + val baseName = "widget_${System.currentTimeMillis()}_${tag.hashCode()}" + + File(dir, "$baseName.jpg").outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) + } + + val meta = JSONObject().apply { + put("labelText", labelText ?: JSONObject.NULL) + put("dateText", dateText ?: JSONObject.NULL) + put("locationText", locationText ?: JSONObject.NULL) + put("photoUri", photoUri ?: JSONObject.NULL) + } + File(dir, "$baseName.json").writeText(meta.toString()) + + // Prune old files + val files = dir.listFiles() + ?.filter { it.name.startsWith("widget_") && it.extension == "jpg" } + ?.sortedByDescending { it.lastModified() } + ?: return + + if (files.size > MAX_CACHED) { + files.drop(MAX_CACHED).forEach { f -> + f.delete() + File(f.absolutePath.replace(".jpg", ".json")).delete() + } + } + } + + private fun buildOkHttpClient(trustAll: Boolean): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + + if (trustAll) { + val tm = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, t: String) {} + override fun checkServerTrusted(chain: Array, t: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + val sc = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), SecureRandom()) } + builder.sslSocketFactory(sc.socketFactory, tm).hostnameVerifier { _, _ -> true } + } + + return builder.build() + } + + private fun buildApiRequest(baseUrl: String, path: String, authHeader: String): Request = + Request.Builder() + .url("$baseUrl$path") + .header("Authorization", authHeader) + .header("User-Agent", "MemoriesNative/1.0") + .header("OCS-APIRequest", "true") + .header("X-Requested-With", "gallery.memories") + .get() + .build() + + companion object { + private const val TAG = "CacheRefreshWorker" + private const val CACHE_DIR = "widget_cache" + private const val MAX_CACHED = 20 + private const val BATCH_SIZE = 5 + private const val WORK_NAME = "MemoriesWidgetCacheRefresh" + private const val REFRESH_INTERVAL_MINUTES = 5L + + /** Schedule the periodic cache refresh. Safe to call multiple times. */ + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder( + REFRESH_INTERVAL_MINUTES, TimeUnit.MINUTES, + ).build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** Cancel the periodic cache refresh. */ + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/android/app/src/main/java/gallery/memories/widget/MemoriesWidget.kt b/android/app/src/main/java/gallery/memories/widget/MemoriesWidget.kt new file mode 100644 index 000000000..1f2e96340 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/widget/MemoriesWidget.kt @@ -0,0 +1,114 @@ +package gallery.memories.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import androidx.work.* +import gallery.memories.MainActivity +import gallery.memories.R +import java.util.concurrent.TimeUnit + +class MemoriesWidget : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + scheduleWidgetUpdate(context) + } + + override fun onEnabled(context: Context) { + scheduleWidgetUpdate(context) + CacheRefreshWorker.schedule(context) + } + + override fun onDisabled(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + CacheRefreshWorker.cancel(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + for (id in appWidgetIds) { + WidgetPrefs.removeWidget(context, id) + } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == ACTION_REFRESH) { + val oneTimeRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_REFRESH, + ExistingWorkPolicy.REPLACE, + oneTimeRequest, + ) + } + } + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ) { + val views = RemoteViews(context.packageName, R.layout.widget_memories) + + val openIntent = Intent(context, MainActivity::class.java) + val openPending = PendingIntent.getActivity( + context, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + views.setOnClickPendingIntent(R.id.widget_root, openPending) + + val refreshIntent = Intent(context, MemoriesWidget::class.java).apply { + action = ACTION_REFRESH + } + val refreshPending = PendingIntent.getBroadcast( + context, 0, refreshIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + views.setOnClickPendingIntent(R.id.widget_refresh_btn, refreshPending) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun scheduleWidgetUpdate(context: Context) { + val interval = WidgetPrefs.getMinIntervalMinutes(context) + val workRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(interval, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build() + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + workRequest, + ) + + val immediateRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_REFRESH, + ExistingWorkPolicy.REPLACE, + immediateRequest, + ) + } + + companion object { + const val ACTION_REFRESH = "gallery.memories.widget.ACTION_REFRESH" + const val EXTRA_PHOTO_SUBPATH = "gallery.memories.widget.EXTRA_PHOTO_SUBPATH" + const val EXTRA_LOCAL_PHOTO_URI = "gallery.memories.widget.EXTRA_LOCAL_PHOTO_URI" + private const val WORK_NAME = "MemoriesWidgetAutoUpdate" + private const val WORK_NAME_REFRESH = "MemoriesWidgetRefresh" + } +} diff --git a/android/app/src/main/java/gallery/memories/widget/WidgetConfigActivity.kt b/android/app/src/main/java/gallery/memories/widget/WidgetConfigActivity.kt new file mode 100644 index 000000000..26a14925d --- /dev/null +++ b/android/app/src/main/java/gallery/memories/widget/WidgetConfigActivity.kt @@ -0,0 +1,91 @@ +package gallery.memories.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.EditText +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.google.android.material.button.MaterialButton +import gallery.memories.R + +/** + * Configuration activity shown when the user adds a Memories widget. + * Lets the user pick the photo-change interval before the widget is placed. + */ +class WidgetConfigActivity : Activity() { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // If the user backs out, the widget should not be placed + setResult(RESULT_CANCELED) + + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + setContentView(R.layout.activity_widget_config) + + val intervalGroup = findViewById(R.id.interval_group) + val customContainer = findViewById(R.id.custom_input_container) + val customInput = findViewById(R.id.custom_minutes_input) + val btnConfirm = findViewById(R.id.btn_confirm) + + // Show/hide custom input based on selection + intervalGroup.setOnCheckedChangeListener { _, checkedId -> + customContainer.visibility = + if (checkedId == R.id.interval_custom) View.VISIBLE else View.GONE + } + + btnConfirm.setOnClickListener { + val minutes = when (intervalGroup.checkedRadioButtonId) { + R.id.interval_5 -> 5L + R.id.interval_15 -> 15L + R.id.interval_25 -> 25L + R.id.interval_custom -> { + val text = customInput.text.toString().trim() + val value = text.toLongOrNull() + if (value == null || value < 1) { + customInput.error = getString(R.string.widget_config_invalid) + return@setOnClickListener + } + value + } + else -> WidgetPrefs.DEFAULT_INTERVAL_MINUTES + } + + // Save preference + WidgetPrefs.setIntervalMinutes(this, appWidgetId, minutes) + + // Trigger an immediate widget update + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(this).enqueueUniqueWork( + "MemoriesWidgetRefresh", + ExistingWorkPolicy.REPLACE, + request, + ) + + // Also schedule the batch cache refresh + CacheRefreshWorker.schedule(this) + + // Return success + val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + } +} diff --git a/android/app/src/main/java/gallery/memories/widget/WidgetPrefs.kt b/android/app/src/main/java/gallery/memories/widget/WidgetPrefs.kt new file mode 100644 index 000000000..f3aa7a54d --- /dev/null +++ b/android/app/src/main/java/gallery/memories/widget/WidgetPrefs.kt @@ -0,0 +1,52 @@ +package gallery.memories.widget + +import android.content.Context + +/** + * Lightweight wrapper for per-widget SharedPreferences. + * Stores the user-chosen photo change interval for each widget instance. + */ +object WidgetPrefs { + + private const val PREFS_NAME = "memories_widget_prefs" + private const val KEY_INTERVAL_PREFIX = "interval_" + + /** Default photo change interval in minutes. */ + const val DEFAULT_INTERVAL_MINUTES = 5L + + /** Preset interval options in minutes. */ + val INTERVAL_OPTIONS = longArrayOf(5, 15, 25) + + fun getIntervalMinutes(context: Context, appWidgetId: Int): Long { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getLong("$KEY_INTERVAL_PREFIX$appWidgetId", DEFAULT_INTERVAL_MINUTES) + } + + fun setIntervalMinutes(context: Context, appWidgetId: Int, minutes: Long) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putLong("$KEY_INTERVAL_PREFIX$appWidgetId", minutes) + .apply() + } + + fun removeWidget(context: Context, appWidgetId: Int) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove("$KEY_INTERVAL_PREFIX$appWidgetId") + .apply() + } + + /** + * Get the minimum interval across all active widgets. + * Used by the auto-update worker to determine scheduling frequency. + */ + fun getMinIntervalMinutes(context: Context): Long { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val intervals = prefs.all + .filter { it.key.startsWith(KEY_INTERVAL_PREFIX) } + .mapNotNull { (it.value as? Long) } + + return if (intervals.isEmpty()) DEFAULT_INTERVAL_MINUTES + else intervals.min() + } +} diff --git a/android/app/src/main/java/gallery/memories/widget/WidgetWorker.kt b/android/app/src/main/java/gallery/memories/widget/WidgetWorker.kt new file mode 100644 index 000000000..ead8fec4a --- /dev/null +++ b/android/app/src/main/java/gallery/memories/widget/WidgetWorker.kt @@ -0,0 +1,867 @@ +package gallery.memories.widget + +import SecureStorage +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.location.Geocoder +import android.provider.MediaStore +import android.util.Base64 +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.exifinterface.media.ExifInterface +import androidx.media3.common.util.UnstableApi +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import gallery.memories.MainActivity +import gallery.memories.R +import gallery.memories.dao.AppDatabase +import gallery.memories.mapper.Photo +import gallery.memories.mapper.SystemImage +import gallery.memories.service.ConfigService +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.random.Random + +/** + * Background worker that fetches a photo and updates all Memories widget instances. + * + * Photo sources are tried in priority order: + * 1. Nextcloud Memories server (with On-This-Day weighting) + * 2. Cached server images (offline fallback) + * 3. Local Room DB photos + * 4. MediaStore fallback (most recent photo) + * + * After each run the worker self-schedules the next update via [scheduleNextUpdate]. + */ +@OptIn(UnstableApi::class) +class WidgetWorker( + private val context: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(context, workerParams) { + + // ════════════════════════════════════════════════════════════════════════ + // Main entry point + // ════════════════════════════════════════════════════════════════════════ + + override suspend fun doWork(): Result { + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, MemoriesWidget::class.java) + ) + if (appWidgetIds.isEmpty()) return Result.success() + + val photoSources: List Boolean> = listOf( + { tryServerPhoto(appWidgetManager, appWidgetIds) }, + { tryCachedPhoto(appWidgetManager, appWidgetIds) }, + { tryLocalDbPhoto(appWidgetManager, appWidgetIds) }, + { tryMediaStoreFallback(appWidgetManager, appWidgetIds) }, + ) + + val loaded = photoSources.firstNotNullOfOrNull { source -> + try { + if (source()) true else null + } catch (e: Exception) { + Log.e(TAG, "Photo source failed", e) + null + } + } != null + + if (!loaded) { + showError(context.getString(R.string.widget_no_photos)) + } + + scheduleNextUpdate() + return Result.success() + } + + /** Enqueue the next auto-update after the user's chosen interval. */ + private fun scheduleNextUpdate() { + val interval = WidgetPrefs.getMinIntervalMinutes(context) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(interval, TimeUnit.MINUTES) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_AUTO, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + // ════════════════════════════════════════════════════════════════════════ + // Data model + // ════════════════════════════════════════════════════════════════════════ + + /** + * Metadata displayed alongside the widget photo. + * Persisted to a JSON sidecar file so cached photos retain their labels. + */ + private data class WidgetMetadata( + val labelText: String? = null, + val dateText: String? = null, + val locationText: String? = null, + val photoUri: String? = null, + ) { + fun toJson(): JSONObject = JSONObject().apply { + put(KEY_LABEL, labelText ?: JSONObject.NULL) + put(KEY_DATE, dateText ?: JSONObject.NULL) + put(KEY_LOCATION, locationText ?: JSONObject.NULL) + put(KEY_PHOTO_URI, photoUri ?: JSONObject.NULL) + } + + companion object { + private const val KEY_LABEL = "labelText" + private const val KEY_DATE = "dateText" + private const val KEY_LOCATION = "locationText" + private const val KEY_PHOTO_URI = "photoUri" + + fun fromJson(json: JSONObject) = WidgetMetadata( + labelText = json.optString(KEY_LABEL, "").ifBlank { null }, + dateText = json.optString(KEY_DATE, "").ifBlank { null }, + locationText = json.optString(KEY_LOCATION, "").ifBlank { null }, + photoUri = json.optString(KEY_PHOTO_URI, "").ifBlank { null }, + ) + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Image cache + // ════════════════════════════════════════════════════════════════════════ + + private fun getCacheDir(): File { + val dir = File(context.filesDir, CACHE_DIR) + if (!dir.exists()) dir.mkdirs() + return dir + } + + /** + * Save [bitmap] and its [metadata] to the cache directory. + * Maintains a rolling window of [MAX_CACHED] images. + */ + private fun cacheImage(bitmap: Bitmap, tag: String, metadata: WidgetMetadata) { + try { + val dir = getCacheDir() + val baseName = "widget_${System.currentTimeMillis()}_${tag.hashCode()}" + + File(dir, "$baseName.jpg").outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, out) + } + + File(dir, "$baseName.json").writeText(metadata.toJson().toString()) + + pruneCache(dir) + } catch (e: Exception) { + Log.e(TAG, "Failed to cache image", e) + } + } + + /** Delete oldest cached images when the cache exceeds [MAX_CACHED]. */ + private fun pruneCache(dir: File) { + val images = dir.listFiles() + ?.filter { it.name.startsWith("widget_") && it.extension == "jpg" } + ?.sortedByDescending { it.lastModified() } + ?: return + + if (images.size > MAX_CACHED) { + images.drop(MAX_CACHED).forEach { expired -> + expired.delete() + sidecarFor(expired).delete() + } + } + + Log.d(TAG, "Cache: ${images.size.coerceAtMost(MAX_CACHED)} images") + } + + /** Load a random cached image with its metadata. Returns null if cache is empty. */ + private fun loadCachedImage(): Pair? { + return try { + val dir = getCacheDir() + val file = dir.listFiles() + ?.filter { it.name.startsWith("widget_") && it.extension == "jpg" } + ?.randomOrNull() + ?: return null + + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return null + val metadata = readSidecar(sidecarFor(file)) + + Pair(bitmap, metadata) + } catch (e: Exception) { + Log.e(TAG, "Failed to load cached image", e) + null + } + } + + /** Read [WidgetMetadata] from a JSON sidecar, returning empty metadata on failure. */ + private fun readSidecar(file: File): WidgetMetadata { + if (!file.exists()) return WidgetMetadata() + return try { + WidgetMetadata.fromJson(JSONObject(file.readText())) + } catch (e: Exception) { + Log.w(TAG, "Failed to read sidecar metadata", e) + WidgetMetadata() + } + } + + /** Get the `.json` sidecar path for a `.jpg` cache file. */ + private fun sidecarFor(imageFile: File): File = + File(imageFile.absolutePath.replace(".jpg", ".json")) + + // ════════════════════════════════════════════════════════════════════════ + // Source 1: Cached server images + // ════════════════════════════════════════════════════════════════════════ + + private fun tryCachedPhoto( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ): Boolean { + val (bitmap, metadata) = loadCachedImage() ?: return false + Log.d(TAG, "Showing cached server photo (offline mode)") + applyWidgetUpdate(bitmap, metadata, appWidgetManager, appWidgetIds) + return true + } + + // ════════════════════════════════════════════════════════════════════════ + // Source 2: Nextcloud Memories server + // ════════════════════════════════════════════════════════════════════════ + + private suspend fun tryServerPhoto( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ): Boolean { + val store = SecureStorage(context) + val cred = store.getCredentials() ?: run { + Log.d(TAG, "No server credentials stored") + return false + } + + val client = buildOkHttpClient(cred.trustAll) + val authHeader = buildAuthHeader(cred.username, cred.token) + + // 1. Fetch available days + val daysArray = fetchDays(client, cred.url, authHeader) ?: return false + if (daysArray.length() == 0) { + Log.w(TAG, "Server has no days") + return false + } + + // 2. Select day (On-This-Day weighted) + val (dayId, isOtd) = selectDay(daysArray) + + // 3. Pick a random photo from that day + val fileId = fetchRandomPhotoId(client, cred.url, authHeader, dayId) + ?: return false + + // 4. Build metadata (label, date, location, deep link) + val metadata = buildServerMetadata( + client, cred.url, authHeader, + dayId = dayId, fileId = fileId, isOnThisDay = isOtd, + ) + + // 5. Download preview bitmap + val bitmap = downloadPreview(client, cred.url, authHeader, fileId) + ?: return false + + // 6. Cache and display + cacheImage(bitmap, "server_$fileId", metadata) + Log.d(TAG, "Server photo: fileId=$fileId, ${bitmap.width}x${bitmap.height}") + applyWidgetUpdate(bitmap, metadata, appWidgetManager, appWidgetIds) + return true + } + + // -- Server: API calls ------------------------------------------------ + + /** Fetch the list of days from `/api/days`. Returns null on failure. */ + private fun fetchDays( + client: OkHttpClient, + baseUrl: String, + authHeader: String, + ): JSONArray? { + return client.newCall( + buildApiRequest(baseUrl, "api/days", authHeader) + ).execute().use { response -> + if (response.code != 200) { + Log.w(TAG, "Server /api/days returned ${response.code}") + return null + } + JSONArray(response.body.string()) + } + } + + /** Fetch photos for [dayId] and return a random file ID, or null on failure. */ + private fun fetchRandomPhotoId( + client: OkHttpClient, + baseUrl: String, + authHeader: String, + dayId: Long, + ): Long? { + val photosArray = client.newCall( + buildApiRequest(baseUrl, "api/days/$dayId", authHeader) + ).execute().use { response -> + if (response.code != 200) { + Log.w(TAG, "Server /api/days/$dayId returned ${response.code}") + return null + } + JSONArray(response.body.string()) + } + + if (photosArray.length() == 0) { + Log.w(TAG, "Day $dayId has no photos") + return null + } + + val idx = (0 until photosArray.length()).random() + return photosArray.getJSONObject(idx).getLong("fileid") + } + + /** Fetch the address from `/api/image/info/{fileId}`, or null. */ + private fun fetchServerLocation( + client: OkHttpClient, + baseUrl: String, + authHeader: String, + fileId: Long, + ): String? { + return try { + client.newCall( + buildApiRequest(baseUrl, "api/image/info/$fileId", authHeader) + ).execute().use { response -> + if (response.code != 200) return null + val json = JSONObject(response.body.string()) + val raw = json.optString("address", "").ifBlank { null } + raw?.let { simplifyAddress(it) } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch photo location info", e) + null + } + } + + /** Download a preview bitmap for [fileId], or null on failure. */ + private fun downloadPreview( + client: OkHttpClient, + baseUrl: String, + authHeader: String, + fileId: Long, + ): Bitmap? { + val bytes = client.newCall( + buildApiRequest(baseUrl, "api/image/preview/$fileId?x=1024&y=1024", authHeader) + ).execute().use { response -> + if (response.code != 200) { + Log.w(TAG, "Server preview for $fileId returned ${response.code}") + return null + } + response.body.bytes() + } + + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: run { Log.e(TAG, "Failed to decode server preview"); null } + } + + // -- Server: day selection -------------------------------------------- + + /** + * Select a day from [daysArray] with On-This-Day weighting. + * Returns (dayId, isOnThisDay). + */ + private fun selectDay(daysArray: JSONArray): Pair { + val today = LocalDate.now() + val todayMonthDay = today.format(MONTH_DAY_FORMAT) + + val otdCandidates = (0 until daysArray.length()) + .map { daysArray.getJSONObject(it) } + .filter { dayObj -> + val dayDate = LocalDate.ofEpochDay(dayObj.getLong("dayid")) + dayDate.format(MONTH_DAY_FORMAT) == todayMonthDay && dayDate.year != today.year + } + + // Weighted roll: prefer OTD when available + if (otdCandidates.isNotEmpty() && Random.nextDouble() < OTD_WEIGHT) { + val chosen = otdCandidates.random() + Log.d(TAG, "Selected OTD (${otdCandidates.size} candidates)") + return chosen.getLong("dayid") to true + } + + // Random day fallback + val idx = (0 until daysArray.length()).random() + val dayId = daysArray.getJSONObject(idx).getLong("dayid") + Log.d(TAG, if (otdCandidates.isNotEmpty()) "Rolled random (OTD available)" else "No OTD, random day") + return dayId to false + } + + // -- Server: metadata ------------------------------------------------- + + /** Build [WidgetMetadata] for a server photo. */ + private fun buildServerMetadata( + client: OkHttpClient, + baseUrl: String, + authHeader: String, + dayId: Long, + fileId: Long, + isOnThisDay: Boolean, + ): WidgetMetadata { + val dayDate = LocalDate.ofEpochDay(dayId) + val (labelText, dateText) = buildLabelAndDate(isOnThisDay, dayDate) + val locationText = fetchServerLocation(client, baseUrl, authHeader, fileId) + val photoUri = "#v/$dayId/$fileId" + + return WidgetMetadata(labelText, dateText, locationText, photoUri) + } + + // -- HTTP helpers ----------------------------------------------------- + + private fun buildAuthHeader(username: String, token: String): String = + "Basic ${Base64.encodeToString("$username:$token".toByteArray(), Base64.NO_WRAP)}" + + private fun buildOkHttpClient(trustAll: Boolean): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + + if (trustAll) { + val trustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, type: String) {} + override fun checkServerTrusted(chain: Array, type: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + builder.sslSocketFactory(sslContext.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true } + } + + return builder.build() + } + + /** Build an authenticated API request for the Memories server. */ + private fun buildApiRequest(baseUrl: String, path: String, authHeader: String): Request = + Request.Builder() + .url("$baseUrl$path") + .header("Authorization", authHeader) + .header("User-Agent", USER_AGENT) + .header("OCS-APIRequest", "true") + .header("X-Requested-With", PACKAGE_ID) + .get() + .build() + + // ════════════════════════════════════════════════════════════════════════ + // Source 3: Local Room DB + // ════════════════════════════════════════════════════════════════════════ + + private suspend fun tryLocalDbPhoto( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ): Boolean { + if (!hasMediaPermission()) return false + + val photoDao = AppDatabase.get(context).photoDao() + val bucketIds = ConfigService(context).enabledBucketIds + + val today = Instant.now().atZone(ZoneId.systemDefault()) + val dateStr = MONTH_DAY_FORMAT.format(today) + + val otdPhotos = if (bucketIds.isEmpty()) { + photoDao.getOnThisDayPhotosAny(dateStr) + } else { + photoDao.getOnThisDayPhotos(dateStr, bucketIds) + } + + val (photo, isOtd) = selectLocalPhoto(otdPhotos, photoDao, bucketIds) + ?: return false + + val photoDate = Instant.ofEpochSecond(photo.dateTaken) + .atZone(ZoneId.systemDefault()) + val (labelText, dateText) = buildLabelAndDate(isOtd, photoDate.toLocalDate(), today.year) + + val systemImage = SystemImage.getByIds(context, listOf(photo.localId)) + .firstOrNull() ?: return false + + val metadata = WidgetMetadata( + labelText = labelText, + dateText = dateText, + locationText = getLocationFromExif(systemImage), + photoUri = systemImage.uri.toString(), + ) + loadBitmapAndApply(systemImage, metadata, appWidgetManager, appWidgetIds) + return true + } + + /** + * Select a photo with On-This-Day weighting. + * Returns (photo, isOnThisDay), or null if no photos are available. + */ + private fun selectLocalPhoto( + otdPhotos: List, + photoDao: gallery.memories.dao.PhotoDao, + bucketIds: List, + ): Pair? { + if (otdPhotos.isNotEmpty() && Random.nextDouble() < OTD_WEIGHT) { + return otdPhotos.random() to true + } + + val randomPhoto = if (bucketIds.isEmpty()) { + photoDao.getRandomPhotoAny() + } else { + photoDao.getRandomPhoto(bucketIds) + } + + if (randomPhoto != null) return randomPhoto to false + if (otdPhotos.isNotEmpty()) return otdPhotos.random() to true + return null + } + + // ════════════════════════════════════════════════════════════════════════ + // Source 4: MediaStore fallback + // ════════════════════════════════════════════════════════════════════════ + + private suspend fun tryMediaStoreFallback( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ): Boolean { + if (!hasMediaPermission()) return false + + return try { + val systemImage = queryMostRecentImage() ?: return false + val dateText = if (systemImage.dateTaken > 0) { + formatPhotoDate(systemImage.dateTaken / 1000) + } else null + + val metadata = WidgetMetadata( + labelText = getRandomMemoryLabel(), + dateText = dateText, + locationText = getLocationFromExif(systemImage), + photoUri = systemImage.uri.toString(), + ) + loadBitmapAndApply(systemImage, metadata, appWidgetManager, appWidgetIds) + true + } catch (e: Exception) { + Log.e(TAG, "MediaStore fallback error", e) + false + } + } + + /** Query MediaStore for the most recent image (or video if none). */ + private fun queryMostRecentImage(): SystemImage? { + val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC" + + return SystemImage.cursor(context, SystemImage.IMAGE_URI, null, null, sortOrder) + .take(1).toList().firstOrNull() + ?: SystemImage.cursor(context, SystemImage.VIDEO_URI, null, null, sortOrder) + .take(1).toList().firstOrNull() + } + + // ════════════════════════════════════════════════════════════════════════ + // Bitmap loading + // ════════════════════════════════════════════════════════════════════════ + + /** Load a local [systemImage] via Glide and apply the widget update. */ + private suspend fun loadBitmapAndApply( + systemImage: SystemImage, + metadata: WidgetMetadata, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + val bitmap = loadBitmap(systemImage) ?: run { + Log.e(TAG, "Failed to load local bitmap") + return + } + applyWidgetUpdate(bitmap, metadata, appWidgetManager, appWidgetIds) + } + + /** Load a sized bitmap from a [SystemImage] using Glide. */ + private suspend fun loadBitmap(systemImage: SystemImage): Bitmap? = + suspendCoroutine { continuation -> + Glide.with(context) + .asBitmap() + .load(systemImage.uri) + .override(BITMAP_SIZE, BITMAP_SIZE) + .centerCrop() + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, model: Any?, + target: Target, isFirstResource: Boolean, + ): Boolean { + continuation.resume(null) + return false + } + + override fun onResourceReady( + resource: Bitmap, model: Any, + target: Target?, dataSource: DataSource, + isFirstResource: Boolean, + ): Boolean { + continuation.resume(resource) + return false + } + }) + .submit() + } + + // ════════════════════════════════════════════════════════════════════════ + // Widget RemoteViews update + // ════════════════════════════════════════════════════════════════════════ + + /** Apply [bitmap] and [metadata] to all widget instances. */ + private fun applyWidgetUpdate( + bitmap: Bitmap, + metadata: WidgetMetadata, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (appWidgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_memories) + + views.setImageViewBitmap(R.id.widget_image, bitmap) + views.setViewVisibility(R.id.widget_image, View.VISIBLE) + views.setViewVisibility(R.id.widget_empty_text, View.GONE) + + applyLocation(views, metadata.locationText) + applyLabelAndDate(views, metadata.labelText, metadata.dateText) + + views.setOnClickPendingIntent( + R.id.widget_root, buildPhotoPendingIntent(appWidgetId, metadata.photoUri), + ) + views.setOnClickPendingIntent( + R.id.widget_refresh_btn, buildRefreshPendingIntent(), + ) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + /** Show an error message on all widget instances. */ + private fun showError(message: String) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, MemoriesWidget::class.java) + ) + + for (appWidgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_memories) + views.setTextViewText(R.id.widget_empty_text, message) + views.setViewVisibility(R.id.widget_empty_text, View.VISIBLE) + views.setViewVisibility(R.id.widget_image, View.GONE) + views.setViewVisibility(R.id.widget_label, View.GONE) + views.setViewVisibility(R.id.widget_date, View.GONE) + views.setViewVisibility(R.id.widget_location, View.GONE) + + views.setOnClickPendingIntent( + R.id.widget_root, buildPhotoPendingIntent(appWidgetId, photoUri = null), + ) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + // -- RemoteViews helpers ----------------------------------------------- + + private fun applyLocation(views: RemoteViews, locationText: String?) { + if (!locationText.isNullOrBlank()) { + views.setTextViewText(R.id.widget_location, "\uD83D\uDCCD $locationText") + views.setViewVisibility(R.id.widget_location, View.VISIBLE) + } else { + views.setViewVisibility(R.id.widget_location, View.GONE) + } + } + + private fun applyLabelAndDate(views: RemoteViews, labelText: String?, dateText: String?) { + if (!labelText.isNullOrBlank()) { + views.setViewVisibility(R.id.widget_label, View.VISIBLE) + views.setTextViewText(R.id.widget_label, labelText) + if (!dateText.isNullOrBlank()) { + views.setViewVisibility(R.id.widget_date, View.VISIBLE) + views.setTextViewText(R.id.widget_date, dateText) + } else { + views.setViewVisibility(R.id.widget_date, View.GONE) + } + } else { + views.setViewVisibility(R.id.widget_label, View.GONE) + views.setViewVisibility(R.id.widget_date, View.GONE) + } + } + + /** Build a [PendingIntent] that opens the clicked photo (server deep link or local URI). */ + private fun buildPhotoPendingIntent(appWidgetId: Int, photoUri: String?): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (!photoUri.isNullOrBlank()) { + if (photoUri.startsWith("content://")) { + putExtra(MemoriesWidget.EXTRA_LOCAL_PHOTO_URI, photoUri) + } else { + putExtra(MemoriesWidget.EXTRA_PHOTO_SUBPATH, photoUri) + } + } + } + return PendingIntent.getActivity( + context, appWidgetId, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + /** Build a [PendingIntent] for the refresh button. */ + private fun buildRefreshPendingIntent(): PendingIntent { + val intent = Intent(context, MemoriesWidget::class.java).apply { + action = MemoriesWidget.ACTION_REFRESH + } + return PendingIntent.getBroadcast( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + // ════════════════════════════════════════════════════════════════════════ + // Label & date helpers + // ════════════════════════════════════════════════════════════════════════ + + /** Pick a random label from the set of non-OTD memory labels. */ + private fun getRandomMemoryLabel(): String { + val labels = intArrayOf( + R.string.widget_from_memories, + R.string.widget_throwback, + R.string.widget_remember_this, + R.string.widget_rediscover, + ) + return context.getString(labels.random()) + } + + /** + * Build the label and date subtitle for a photo. + * OTD photos show "On this day" / "X years ago"; others show a random label / date. + */ + private fun buildLabelAndDate( + isOnThisDay: Boolean, + photoDate: LocalDate, + currentYear: Int = LocalDate.now().year, + ): Pair { + if (isOnThisDay) { + val diff = currentYear - photoDate.year + val dateText = when { + diff == 1 -> context.getString(R.string.widget_one_year_ago) + diff > 1 -> context.getString(R.string.widget_years_ago, diff) + else -> null + } + return context.getString(R.string.widget_on_this_day) to dateText + } + + return getRandomMemoryLabel() to photoDate.format(DISPLAY_DATE_FORMAT) + } + + /** Format an epoch-second timestamp as e.g. "March 15, 2019". */ + private fun formatPhotoDate(epochSeconds: Long): String = + Instant.ofEpochSecond(epochSeconds) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DISPLAY_DATE_FORMAT) + + // ════════════════════════════════════════════════════════════════════════ + // Location helpers + // ════════════════════════════════════════════════════════════════════════ + + /** Extract GPS coordinates from EXIF and reverse-geocode to a location string. */ + private fun getLocationFromExif(systemImage: SystemImage): String? { + if (systemImage.dataPath.isEmpty() || systemImage.isVideo) return null + return try { + val latLong = ExifInterface(systemImage.dataPath).latLong ?: return null + reverseGeocode(latLong[0], latLong[1]) + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF GPS: ${e.message}") + null + } + } + + /** Reverse-geocode [lat]/[lon] to a compact "City, Country" string. */ + private fun reverseGeocode(lat: Double, lon: Double): String? { + return try { + @Suppress("DEPRECATION") + val addresses = Geocoder(context, Locale.getDefault()).getFromLocation(lat, lon, 1) + if (addresses.isNullOrEmpty()) return null + + val addr = addresses[0] + val parts = mutableListOf() + addr.locality?.let { parts.add(it) } + if (parts.isEmpty()) addr.adminArea?.let { parts.add(it) } + addr.countryName?.let { parts.add(it) } + parts.joinToString(", ").ifBlank { null } + } catch (e: Exception) { + Log.w(TAG, "Geocoding failed: ${e.message}") + null + } + } + + /** + * Simplify a full address to "Area, Country" by taking the first and last parts. + * e.g. "Dayeuhkolot, Kabupaten Bandung, West Java, Java, Indonesia" → "Dayeuhkolot, Indonesia" + */ + private fun simplifyAddress(address: String): String { + val parts = address.split(",").map { it.trim() }.filter { it.isNotBlank() } + return when { + parts.size <= 2 -> parts.joinToString(", ") + else -> "${parts.first()}, ${parts.last()}" + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Permission helper + // ════════════════════════════════════════════════════════════════════════ + + /** Check whether the app has permission to read media images. */ + private fun hasMediaPermission(): Boolean = + ContextCompat.checkSelfPermission(context, "android.permission.READ_MEDIA_IMAGES") == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, "android.permission.READ_EXTERNAL_STORAGE") == + PackageManager.PERMISSION_GRANTED + + // ════════════════════════════════════════════════════════════════════════ + // Constants + // ════════════════════════════════════════════════════════════════════════ + + companion object { + private const val TAG = "MemoriesWidgetWorker" + private const val CACHE_DIR = "widget_cache" + private const val MAX_CACHED = 20 + private const val IMAGE_QUALITY = 85 + private const val BITMAP_SIZE = 800 + private const val USER_AGENT = "MemoriesNative/1.0" + private const val PACKAGE_ID = "gallery.memories" + private const val WORK_NAME_AUTO = "MemoriesWidgetAutoUpdate" + + /** Probability (0.0–1.0) of showing an On-This-Day photo when candidates exist. */ + private const val OTD_WEIGHT = 0.7 + + private val MONTH_DAY_FORMAT = DateTimeFormatter.ofPattern("MM-dd") + private val DISPLAY_DATE_FORMAT = DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.getDefault()) + } +} diff --git a/android/app/src/main/res/drawable/ic_widget_refresh.xml b/android/app/src/main/res/drawable/ic_widget_refresh.xml new file mode 100644 index 000000000..1d774e357 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_refresh.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 000000000..7374d21f1 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_gradient_scrim.xml b/android/app/src/main/res/drawable/widget_gradient_scrim.xml new file mode 100644 index 000000000..6e5f3fabc --- /dev/null +++ b/android/app/src/main/res/drawable/widget_gradient_scrim.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_gradient_scrim_top.xml b/android/app/src/main/res/drawable/widget_gradient_scrim_top.xml new file mode 100644 index 000000000..1d1dc8551 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_gradient_scrim_top.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_refresh_circle.xml b/android/app/src/main/res/drawable/widget_refresh_circle.xml new file mode 100644 index 000000000..f8f10879f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_refresh_circle.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_widget_config.xml b/android/app/src/main/res/layout/activity_widget_config.xml new file mode 100644 index 000000000..9bc99a71e --- /dev/null +++ b/android/app/src/main/res/layout/activity_widget_config.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_memories.xml b/android/app/src/main/res/layout/widget_memories.xml new file mode 100644 index 000000000..0d58bf25c --- /dev/null +++ b/android/app/src/main/res/layout/widget_memories.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 83faa37b4..2ff0abd24 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -14,4 +14,27 @@ Your server does not have the minimum required version of Memories Logged out from server Failed to connect to server. Reset app data if this persists. + + Rediscover your memories from this day + Memories + On this day + %d years ago + 1 year ago + From your memories + Throwback + Remember this? + Rediscover + Next photo + No photos found + No folders selected. Open app to configure. + + Widget Setup + Choose how often the photo changes + Every 5 minutes + Every 15 minutes + Every 25 minutes + Custom + minutes + Add Widget + Enter a number (1 or more) \ No newline at end of file diff --git a/android/app/src/main/res/xml/widget_info.xml b/android/app/src/main/res/xml/widget_info.xml new file mode 100644 index 000000000..f2824aa57 --- /dev/null +++ b/android/app/src/main/res/xml/widget_info.xml @@ -0,0 +1,16 @@ + + diff --git a/android/gradle.properties b/android/gradle.properties index 2f711858b..47e223ede 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -19,14 +19,6 @@ android.useAndroidX=true # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.nonFinalResIds=false -android.defaults.buildfeatures.resvalues=true -android.sdk.defaultTargetSdkToCompileSdkIfUnset=false -android.enableAppCompileTimeRClass=false -android.usesSdkInManifest.disallowed=false android.uniquePackageNames=false -android.dependency.useConstraints=true -android.r8.strictFullModeForKeepRules=false -android.r8.optimizedResourceShrinking=false -android.builtInKotlin=false -android.newDsl=false \ No newline at end of file +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index 1bc11b918..8087746b4 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -14,3 +14,4 @@ dependencyResolutionManagement { } rootProject.name = "Memories" include ':app' +include ':wear' diff --git a/android/wear/build.gradle b/android/wear/build.gradle new file mode 100644 index 000000000..88c85a8e3 --- /dev/null +++ b/android/wear/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'gallery.memories.wear' + compileSdk = 36 + + defaultConfig { + applicationId "gallery.memories.wear" + minSdk 30 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +dependencies { + // Wear OS Tiles + implementation 'androidx.wear.tiles:tiles:1.4.1' + implementation 'androidx.wear.tiles:tiles-material:1.4.1' + implementation 'androidx.wear.protolayout:protolayout:1.2.1' + implementation 'androidx.wear.protolayout:protolayout-material:1.2.1' + implementation 'androidx.wear.protolayout:protolayout-expression:1.2.1' + + // Kotlin / Coroutines + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0' + + // WorkManager for periodic photo refresh + implementation 'androidx.work:work-runtime-ktx:2.9.1' + + // OkHttp for server communication + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + + // AndroidX core + implementation 'androidx.core:core-ktx:1.17.0' +} diff --git a/android/wear/proguard-rules.pro b/android/wear/proguard-rules.pro new file mode 100644 index 000000000..091135674 --- /dev/null +++ b/android/wear/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +-keep class gallery.memories.wear.** { *; } +-dontwarn com.google.android.gms.** diff --git a/android/wear/src/main/AndroidManifest.xml b/android/wear/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7cb2e82d4 --- /dev/null +++ b/android/wear/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/wear/src/main/java/gallery/memories/wear/config/WearConfigActivity.kt b/android/wear/src/main/java/gallery/memories/wear/config/WearConfigActivity.kt new file mode 100644 index 000000000..fe49a1d39 --- /dev/null +++ b/android/wear/src/main/java/gallery/memories/wear/config/WearConfigActivity.kt @@ -0,0 +1,79 @@ +package gallery.memories.wear.config + +import android.app.Activity +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import gallery.memories.wear.R +import gallery.memories.wear.tile.PhotoRefreshWorker + +/** + * Simple configuration activity for the Wear OS tile. + * Lets the user enter their Nextcloud Memories server URL and credentials. + */ +class WearConfigActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_wear_config) + + val inputUrl = findViewById(R.id.input_url) + val inputUsername = findViewById(R.id.input_username) + val inputPassword = findViewById(R.id.input_password) + val checkTrustAll = findViewById(R.id.check_trust_all) + val btnSave = findViewById