Fix potential crashes with icon downloader and backuper

This commit is contained in:
Philipp Heckel 2026-01-11 09:26:26 -05:00
parent fb4b20e6b4
commit 1fa72d9bfd
6 changed files with 34 additions and 15 deletions

View file

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

View file

@ -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")
}
}
}
}

View file

@ -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)
}

View file

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

View file

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

View file

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