Repository: tuskyapp/Tusky (language: kotlin)

Issue:
Tusky 26: Repeated force-close in notifications view (Friendica)
### Detailed description

Unsure how to completely reproduce or describe this: Been using Tusky with Friendica quite reliably for white a while now, but with the 26 (Google Play) release this has turned pretty unstable. Observations:

- Notifications don't seem to appear or be loaded anymore at all.
- Opening the notification tab does display "something" but this doesn't seem to be up-to-date most of the time.
- Trying to refresh notifications will make the app crash / force-close.

What other information can I provide to track this down...?

### Steps to reproduce the problem

_No response_

### Debug information

Tusky 26 (Google Play build)
Android 12
loma.ml instance (Friendica 2024.06rc)


Discussion hints:
What kind of notifications do you have? Any that are very unusual / friendica specific? I tried reproducing this on my opensocial.at account with a bunch of mentions and likes and it works just fine.
To be honest: I'm not sure. Just the "usual stuff" I guess. Some comments, some likes. Nothing I'd see as specific or surprising. Any way to debug what's going on here...?
Can you have a look at the logcat output during the crash or take a bug report?
> Can you have a look at the logcat output during the crash or take a bug report?

Still being unable to capture a crash report, I _think_ I found a trace here. In Friendica, I subscribed to the Friendica Helpers group ...

https://forum.friendi.ca/profile/helpers

... and set that to "notifications on new posts" which will make those appear in my notification window. A couple of times, now, I noticed crashes of tusky whenever such notifications appeared there, and the app reproducibly stopped crashing the very moment I used some other client to remove this particular notification. Not sure what could cause that, though.
Ok I subscribed to that group, let's see what happens on the next notification

Pull request: fix crash when there are reblogs in notification statuses
```
android.database.sqlite.SQLiteConstraintException: FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)
    at android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
    at android.database.sqlite.SQLiteConnection.executeForLastInsertedRowId(SQLiteConnection.java:961)
    at android.database.sqlite.SQLiteSession.executeForLastInsertedRowId(SQLiteSession.java:790)
    at android.database.sqlite.SQLiteStatement.executeInsert(SQLiteStatement.java:89)
    at androidx.sqlite.db.framework.FrameworkSQLiteStatement.executeInsert(FrameworkSQLiteStatement.kt:42)
    at androidx.room.EntityInsertionAdapter.insertAndReturnId(EntityInsertionAdapter.kt:101)
    at com.keylesspalace.tusky.db.dao.TimelineStatusDao_Impl$insert$2.call(TimelineStatusDao_Impl.kt:345)
    at com.keylesspalace.tusky.db.dao.TimelineStatusDao_Impl$insert$2.call(TimelineStatusDao_Impl.kt:340)
    at androidx.room.CoroutinesRoom$Companion.execute(CoroutinesRoom.kt:56)
    at com.keylesspalace.tusky.db.dao.TimelineStatusDao_Impl.insert(TimelineStatusDao_Impl.kt:340)
    at com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediator.replaceNotificationRange(NotificationsRemoteMediator.kt:169)
    at com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediator.access$replaceNotificationRange(NotificationsRemoteMediator.kt:36)
    at com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediator$load$3.invokeSuspend(NotificationsRemoteMediator.kt:109)
    at com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediator$load$3.invoke(Unknown Source:8)
    at com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediator$load$3.invoke(Unknown Source:2)
    at androidx.room.RoomDatabaseKt$withTransaction$transactionBlock$1.invokeSuspend(RoomDatabaseExt.kt:62)
    at androidx.room.RoomDatabaseKt$withTransaction$transactionBlock$1.invoke(Unknown Source:8)
    at androidx.room.RoomDatabaseKt$withTransaction$transactionBlock$1.invoke(Unknown Source:4)
    at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:61)
    at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:163)
    at kotlinx.coroutines.BuildersKt.withContext(Unknown Source:1)
    at androidx.room.RoomDatabaseKt$startTransactionCoroutine$2$1$1.invokeSuspend(RoomDatabaseExt.kt:103)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
    at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
    at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:95)
    at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:69)
    at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
    at androidx.room.RoomDatabaseKt$startTransactionCoroutine$2$1.run(RoomDatabaseExt.kt:99)
    at androidx.room.TransactionExecutor.execute$lambda$1$lambda$0(TransactionExecutor.kt:36)
    at androidx.room.TransactionExecutor.$r8$lambda$FZWr2PGmP3sgXLCiri-DCcePXSs(Unknown Source:0)
    at androidx.room.TransactionExecutor$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
    at java.lang.Thread.run(Thread.java:1012)
```

It looks kinda weird because "x just posted" has a different user than the actual post, but it works for groups I guess? And definitely better than crashing.

<img src="https://github.com/user-attachments/assets/8110ff17-674d-4f36-8df0-453a666856a6" width="320"/>

closes #4563 

Test command: ./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.components.notifications.NotificationsRemoteMediatorTest"

Fix patch:
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
index d3248a6720..0ad4deffca 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
@@ -47,7 +47,7 @@ fun Notification.toEntity(
     type = type,
     id = id,
     accountId = account.id,
-    statusId = status?.id,
+    statusId = status?.reblog?.id ?: status?.id,
     reportId = report?.id,
     loading = false
 )
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt
index d6627da581..d3b71f7507 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt
@@ -164,10 +164,10 @@ class NotificationsRemoteMediator(
                 val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive)
                 val contentCollapsed = oldStatus?.contentCollapsed ?: true
 
-                accountDao.insert(status.account.toEntity(activeAccount.id))
-
+                val statusToInsert = status.reblog ?: status
+                accountDao.insert(statusToInsert.account.toEntity(activeAccount.id))
                 statusDao.insert(
-                    status.toEntity(
+                    statusToInsert.toEntity(
                         tuskyAccountId = activeAccount.id,
                         expanded = expanded,
                         contentShowing = contentShowing,
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
index 3777b84fee..5e74003ecc 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
@@ -345,10 +345,11 @@ class NotificationsViewModel @Inject constructor(
                             notificationsDao.insertReport(report.toEntity(account.id))
                         }
                         notification.status?.let { status ->
-                            accountDao.insert(status.account.toEntity(account.id))
+                            val statusToInsert = status.reblog ?: status
+                            accountDao.insert(statusToInsert.account.toEntity(account.id))
 
                             statusDao.insert(
-                                status.toEntity(
+                                statusToInsert.toEntity(
                                     tuskyAccountId = account.id,
                                     expanded = account.alwaysOpenSpoiler,
                                     contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive,


Test patch:
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
index 0f2c9abb43..34ae432bb1 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
@@ -11,6 +11,7 @@ import androidx.room.Room
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
 import com.keylesspalace.tusky.components.timeline.Placeholder
+import com.keylesspalace.tusky.components.timeline.fakeStatus
 import com.keylesspalace.tusky.db.AccountManager
 import com.keylesspalace.tusky.db.AppDatabase
 import com.keylesspalace.tusky.db.Converters
@@ -215,7 +216,17 @@ class NotificationsRemoteMediatorTest {
             api = mock {
                 onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(
                     listOf(
-                        fakeNotification(id = "8"),
+                        // testing for https://github.com/tuskyapp/Tusky/issues/4563
+                        fakeNotification(
+                            id = "8",
+                            status = fakeStatus(
+                                id = "r1",
+                                reblog = fakeStatus(
+                                    id = "8",
+                                    authorServerId = "r1"
+                                )
+                            )
+                        ),
                         fakeNotification(id = "7"),
                         fakeNotification(id = "5")
                     )
@@ -249,7 +260,13 @@ class NotificationsRemoteMediatorTest {
 
         db.assertNotifications(
             listOf(
-                fakeNotification(id = "8").toNotificationDataEntity(1),
+                fakeNotification(
+                    id = "8",
+                    status = fakeStatus(
+                        id = "8",
+                        authorServerId = "r1"
+                    )
+                ).toNotificationDataEntity(1),
                 fakeNotification(id = "7").toNotificationDataEntity(1),
                 fakeNotification(id = "5").toNotificationDataEntity(1),
                 fakeNotification(id = "3").toNotificationDataEntity(1),
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineFaker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineFaker.kt
index 2351347dc6..80fc0c25d7 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineFaker.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineFaker.kt
@@ -35,7 +35,8 @@ fun fakeStatus(
     reblogged: Boolean = false,
     favourited: Boolean = true,
     bookmarked: Boolean = true,
-    domain: String = "mastodon.example"
+    domain: String = "mastodon.example",
+    reblog: Status? = null
 ) = Status(
     id = id,
     url = "https://$domain/@ConnyDuck/$id",
@@ -45,7 +46,7 @@ fun fakeStatus(
     ),
     inReplyToId = inReplyToId,
     inReplyToAccountId = inReplyToAccountId,
-    reblog = null,
+    reblog = reblog,
     content = "Test",
     createdAt = fixedDate,
     editedAt = null,