From 1fa72d9bfda2114a3512cf6f5e88665d246ff026 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 11 Jan 2026 09:26:26 -0500 Subject: [PATCH] Fix potential crashes with icon downloader and backuper --- .../java/io/heckel/ntfy/backup/Backuper.kt | 6 +++--- .../main/java/io/heckel/ntfy/db/Database.kt | 18 +++++++++++++++--- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 17 +++++++++++------ .../heckel/ntfy/msg/NotificationDispatcher.kt | 2 +- .../heckel/ntfy/service/SubscriberService.kt | 5 +++-- .../metadata/android/en-US/changelog/NEXT.txt | 1 + 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index d3e0cd7e..8ab61e8a 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -175,7 +175,7 @@ class Backuper(val context: Context) { } else { null } - val icon = if (n.icon != null) { + val icon = if (n.icon != null && !n.icon.url.isNullOrEmpty()) { io.heckel.ntfy.db.Icon( url = n.icon.url, contentUri = n.icon.contentUri, @@ -331,7 +331,7 @@ class Backuper(val context: Context) { } else { null } - val icon = if (n.icon != null) { + val icon = if (n.icon != null && n.icon.hasValidUrl()) { Icon( url = n.icon.url, contentUri = n.icon.contentUri, @@ -479,7 +479,7 @@ data class Attachment( ) data class Icon( - val url: String, // URL (mandatory, see ntfy server) + val url: String?, // URL (nullable to handle corrupt backup files) val contentUri: String?, // After it's downloaded, the content:// location ) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index a76c730c..6110639f 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -137,11 +137,13 @@ const val ATTACHMENT_PROGRESS_DONE = 100 @Entity data class Icon( - @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) + @ColumnInfo(name = "url") val url: String?, // URL (nullable to handle corrupt data from backup restore) @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location ) { - @Ignore constructor(url:String) : + @Ignore constructor(url: String) : this(url, null) + + fun hasValidUrl(): Boolean = !url.isNullOrEmpty() } @Entity @@ -222,7 +224,7 @@ data class LogEntry( } @androidx.room.Database( - version = 16, + version = 17, entities = [ Subscription::class, Notification::class, @@ -266,6 +268,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_13_14) .addMigrations(MIGRATION_14_15) .addMigrations(MIGRATION_15_16) + .addMigrations(MIGRATION_16_17) .fallbackToDestructiveMigration(true) .build() this.instance = instance @@ -390,6 +393,15 @@ abstract class Database : RoomDatabase() { db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))") } } + + // Fix corrupt icon data where icon_url is NULL but icon_contentUri is not NULL + // This caused IllegalStateException in CursorWindow.nativeGetString when Room tried to + // construct an Icon object with a null URL (Icon.url is non-nullable in Kotlin) + private val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL") + } + } } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 8c4ffd60..c9815fb7 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -36,6 +36,10 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) notification = repository.getNotification(notificationId) ?: return Result.failure() subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() icon = notification.icon ?: return Result.failure() + if (!icon.hasValidUrl()) { + Log.w(TAG, "Icon has no valid URL, skipping download") + return Result.failure() + } try { val iconFile = createIconFile(icon) val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS @@ -58,12 +62,13 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) } private suspend fun downloadIcon(iconFile: File) { - Log.d(TAG, "Downloading icon from ${icon.url}") + val iconUrl = icon.url!! // Validated in doWork() + Log.d(TAG, "Downloading icon from $iconUrl") try { - val user = repository.getUser(extractBaseUrl(icon.url)) - val customHeaders = repository.getCustomHeaders(extractBaseUrl(icon.url)) - val request = HttpUtil.requestBuilder(icon.url, user, customHeaders).build() - val client = HttpUtil.defaultClient(context, extractBaseUrl(icon.url)) + val user = repository.getUser(extractBaseUrl(iconUrl)) + val customHeaders = repository.getCustomHeaders(extractBaseUrl(iconUrl)) + val request = HttpUtil.requestBuilder(iconUrl, user, customHeaders).build() + val client = HttpUtil.defaultClient(context, extractBaseUrl(iconUrl)) client.newCall(request).execute().use { response -> Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}") if (!response.isSuccessful) { @@ -142,7 +147,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) if (!iconDir.exists() && !iconDir.mkdirs()) { throw Exception("Cannot create cache directory for icons: $iconDir") } - val hash = icon.url.sha256() + val hash = icon.url!!.sha256() // URL validated in doWork() return File(iconDir, hash) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 3afabdb5..f77c9b0b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -72,7 +72,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } private fun shouldDownloadIcon(notification: Notification): Boolean { - return notification.icon != null + return notification.icon?.hasValidUrl() == true } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 03bca83d..68ada1aa 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -115,11 +115,12 @@ class SubscriberService : Service() { notificationManager = createNotificationChannel() serviceNotification = createNotification(title, text) + val notification = serviceNotification ?: return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_SERVICE_ID, serviceNotification!!, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + startForeground(NOTIFICATION_SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) } else { - startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) + startForeground(NOTIFICATION_SERVICE_ID, notification) } } catch (e: Exception) { // On Android 12+, starting a foreground service from the background is restricted. diff --git a/fastlane/metadata/android/en-US/changelog/NEXT.txt b/fastlane/metadata/android/en-US/changelog/NEXT.txt index f95e93b3..a7290ea7 100644 --- a/fastlane/metadata/android/en-US/changelog/NEXT.txt +++ b/fastlane/metadata/android/en-US/changelog/NEXT.txt @@ -5,3 +5,4 @@ Maintenance + bug fixes: * Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting) * Fix crash in sharing dialog (thanks to @rogeliodh) * Fix crash when exiting multi-delete in detail view +* Fix potential crashes with icon downloader and backuper