Load goto social and microblog.pub urls in the app

- goto social status urls
- goto social profile urls
- microblog.pub status urls

(microblog.pub profile urls are just site urls, so 🤷 )

I also moved the utility function into `LinkHelper`, it felt weird to keep it in `BottomSheetActivity`

 #2893

Missing support for GoToSocial and microblog.pub links in looksLikeMastodonUrl
Tusky currently supports automated lookup of posts (i.e., URLs inside other posts) from Mastodon, Pleroma, Friendica, Misskey, and Pixelfed, but it fails when it comes to [GoToSocial](https://gotosocial.org/) or [microblog.pub](https://docs.microblog.pub/). I have absolutely no experience with Kotlin or the workflow here, so I'm not really comfortable with creating a pull request myself. But if someone is willing to do it, I believe the correct regexes are as follows:

* `"^/@[^/]+/statuses/[0-9ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz]{26}$"` for GoToSocial posts
  * It uses ULID, so it's guaranteed to be 26 symbols in Crockford's Base 32. And it seems to be case-insensitive.
  * See https://github.com/superseriousbusiness/gotosocial/blob/25edd57eaf3eecf0b449a4dabb5b5fc5d6ae7687/internal/id/ulid.go#L35 and https://github.com/oklog/ulid
* `"^/o/[0-9a-f]{32}$"` for microblog.pub posts
  * It uses UUID, so it's guaranteed to be 32 hex symbols. It is case-sensitive (lowercase).
  * See https://git.sr.ht/~tsileo/microblog.pub/tree/v2/item/app/boxes.py#L51
* Optionally `"^/articles/[0-9a-f]{7}/[-\\w]+$"` for microblog.pub articles
  * Articles use a combination of the short id (currently first 7 characters of the actual id) and a slug.
  * Maybe something more general like `"^/articles/[0-9a-f]+/[-\\w]+$"` should be used in case they decide to change the length of the short id.
  * This is not necessary as it would complicate getting to the actual article. Users would be forced to use the "Open in browser" button to see the whole content. (Mastodon shows only the name of the article.)
  * See https://git.sr.ht/~tsileo/microblog.pub/tree/v2/item/app/boxes.py#L658 and https://git.sr.ht/~tsileo/microblog.pub/tree/v2/item/app/utils/text.py#L5

It's supposed to go in Tusky/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt.

* * * *
- Tusky Version: 19.0
- Android Version: 11
- Android Device: FP4
- Mastodon instance (if applicable):

- [X] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.BottomSheetActivityTest" --tests "com.keylesspalace.tusky.util.LinkHelperTest"

diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
index 36a476f2fb..60d1966d1c 100644
--- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
@@ -29,10 +29,9 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.keylesspalace.tusky.components.account.AccountActivity
 import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
 import com.keylesspalace.tusky.network.MastodonApi
+import com.keylesspalace.tusky.util.looksLikeMastodonUrl
 import com.keylesspalace.tusky.util.openLink
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
-import java.net.URI
-import java.net.URISyntaxException
 import javax.inject.Inject

 /** this is the base class for all activities that open links
@@ -173,45 +172,6 @@ abstract class BottomSheetActivity : BaseActivity() {
     }
 }

-// https://mastodon.foo.bar/@User
-// https://mastodon.foo.bar/@User/43456787654678
-// https://pleroma.foo.bar/users/User
-// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0
-// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc
-// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
-// https://friendica.foo.bar/profile/user
-// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
-// https://misskey.foo.bar/notes/83w6r388br (always lowercase)
-// https://pixelfed.social/p/connyduck/391263492998670833
-// https://pixelfed.social/connyduck
-fun looksLikeMastodonUrl(urlString: String): Boolean {
-    val uri: URI
-    try {
-        uri = URI(urlString)
-    } catch (e: URISyntaxException) {
-        return false
-    }
-
-    if (uri.query != null ||
-        uri.fragment != null ||
-        uri.path == null
-    ) {
-        return false
-    }
-
-    val path = uri.path
-    return path.matches("^/@[^/]+$".toRegex()) ||
-        path.matches("^/@[^/]+/\\d+$".toRegex()) ||
-        path.matches("^/users/\\w+$".toRegex()) ||
-        path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
-        path.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
-        path.matches("^/notes/[a-z0-9]+$".toRegex()) ||
-        path.matches("^/display/[-a-f0-9]+$".toRegex()) ||
-        path.matches("^/profile/\\w+$".toRegex()) ||
-        path.matches("^/p/\\w+/\\d+$".toRegex()) ||
-        path.matches("^/\\w+$".toRegex())
-}
-
 enum class PostLookupFallbackBehavior {
     OPEN_IN_BROWSER,
     DISPLAY_ERROR,
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
index 39489e4a0c..69f84593ca 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
@@ -37,6 +37,8 @@ import com.keylesspalace.tusky.R
 import com.keylesspalace.tusky.entity.HashTag
 import com.keylesspalace.tusky.entity.Status.Mention
 import com.keylesspalace.tusky.interfaces.LinkListener
+import java.net.URI
+import java.net.URISyntaxException

 fun getDomain(urlString: String?): String {
     val host = urlString?.toUri()?.host
@@ -270,4 +272,49 @@ private fun openLinkInCustomTab(uri: Uri, context: Context) {
     }
 }

+// https://mastodon.foo.bar/@User
+// https://mastodon.foo.bar/@User/43456787654678
+// https://pleroma.foo.bar/users/User
+// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0
+// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc
+// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
+// https://friendica.foo.bar/profile/user
+// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
+// https://misskey.foo.bar/notes/83w6r388br (always lowercase)
+// https://pixelfed.social/p/connyduck/391263492998670833
+// https://pixelfed.social/connyduck
+// https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2
+// https://gts.foo.bar/@goblin
+// https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5
+fun looksLikeMastodonUrl(urlString: String): Boolean {
+    val uri: URI
+    try {
+        uri = URI(urlString)
+    } catch (e: URISyntaxException) {
+        return false
+    }
+
+    if (uri.query != null ||
+        uri.fragment != null ||
+        uri.path == null
+    ) {
+        return false
+    }
+
+    return uri.path.let {
+        it.matches("^/@[^/]+$".toRegex()) ||
+            it.matches("^/@[^/]+/\\d+$".toRegex()) ||
+            it.matches("^/users/\\w+$".toRegex()) ||
+            it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
+            it.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
+            it.matches("^/notes/[a-z0-9]+$".toRegex()) ||
+            it.matches("^/display/[-a-f0-9]+$".toRegex()) ||
+            it.matches("^/profile/\\w+$".toRegex()) ||
+            it.matches("^/p/\\w+/\\d+$".toRegex()) ||
+            it.matches("^/\\w+$".toRegex()) ||
+            it.matches("^/@[^/]+/statuses/[a-zA-Z0-9]+$".toRegex()) ||
+            it.matches("^/o/[a-f0-9]+$".toRegex())
+    }
+}
+
 private const val TAG = "LinkHelper"

diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
index 4c77799976..142893cee7 100644
--- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
@@ -30,8 +30,6 @@ import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
 import org.mockito.ArgumentMatchers.anyBoolean
 import org.mockito.Mockito.eq
 import org.mockito.kotlin.doReturn
@@ -108,70 +106,6 @@ class BottomSheetActivityTest {
         activity = FakeBottomSheetActivity(apiMock)
     }

-    @RunWith(Parameterized::class)
-    class UrlMatchingTests(private val url: String, private val expectedResult: Boolean) {
-        companion object {
-            @Parameterized.Parameters(name = "match_{0}")
-            @JvmStatic
-            fun data(): Iterable<Any> {
-                return listOf(
-                    arrayOf("https://mastodon.foo.bar/@User", true),
-                    arrayOf("http://mastodon.foo.bar/@abc123", true),
-                    arrayOf("https://mastodon.foo.bar/@user/345667890345678", true),
-                    arrayOf("https://mastodon.foo.bar/@user/3", true),
-                    arrayOf("https://pleroma.foo.bar/users/meh3223", true),
-                    arrayOf("https://pleroma.foo.bar/users/meh3223_bruh", true),
-                    arrayOf("https://pleroma.foo.bar/users/2345", true),
-                    arrayOf("https://pleroma.foo.bar/notice/9", true),
-                    arrayOf("https://pleroma.foo.bar/notice/9345678", true),
-                    arrayOf("https://pleroma.foo.bar/notice/wat", true),
-                    arrayOf("https://pleroma.foo.bar/notice/9qTHT2ANWUdXzENqC0", true),
-                    arrayOf("https://pleroma.foo.bar/objects/abcdef-123-abcd-9876543", true),
-                    arrayOf("https://misskey.foo.bar/notes/mew", true),
-                    arrayOf("https://misskey.foo.bar/notes/1421564653", true),
-                    arrayOf("https://misskey.foo.bar/notes/qwer615985ddf", true),
-                    arrayOf("https://friendica.foo.bar/profile/user", true),
-                    arrayOf("https://friendica.foo.bar/profile/uSeR", true),
-                    arrayOf("https://friendica.foo.bar/profile/user_user", true),
-                    arrayOf("https://friendica.foo.bar/profile/123", true),
-                    arrayOf("https://friendica.foo.bar/display/abcdef-123-abcd-9876543", true),
-                    arrayOf("https://google.com/", false),
-                    arrayOf("https://mastodon.foo.bar/@User?foo=bar", false),
-                    arrayOf("https://mastodon.foo.bar/@User#foo", false),
-                    arrayOf("http://mastodon.foo.bar/@", false),
-                    arrayOf("http://mastodon.foo.bar/@/345678", false),
-                    arrayOf("https://mastodon.foo.bar/@user/345667890345678/", false),
-                    arrayOf("https://mastodon.foo.bar/@user/3abce", false),
-                    arrayOf("https://pleroma.foo.bar/users/", false),
-                    arrayOf("https://pleroma.foo.bar/users/meow/", false),
-                    arrayOf("https://pleroma.foo.bar/users/@meow", false),
-                    arrayOf("https://pleroma.foo.bar/user/2345", false),
-                    arrayOf("https://pleroma.foo.bar/notices/123456", false),
-                    arrayOf("https://pleroma.foo.bar/notice/@neverhappen/", false),
-                    arrayOf("https://pleroma.foo.bar/object/abcdef-123-abcd-9876543", false),
-                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543", false),
-                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543/", false),
-                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd_9876543", false),
-                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd-9876543", false),
-                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd-9876543/", false),
-                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd_9876543", false),
-                    arrayOf("https://friendica.foo.bar/profile/@mew", false),
-                    arrayOf("https://friendica.foo.bar/profile/@mew/", false),
-                    arrayOf("https://misskey.foo.bar/notes/@nyan", false),
-                    arrayOf("https://misskey.foo.bar/notes/NYAN123", false),
-                    arrayOf("https://misskey.foo.bar/notes/meow123/", false),
-                    arrayOf("https://pixelfed.social/p/connyduck/391263492998670833", true),
-                    arrayOf("https://pixelfed.social/connyduck", true)
-                )
-            }
-        }
-
-        @Test
-        fun test() {
-            assertEquals(expectedResult, looksLikeMastodonUrl(url))
-        }
-    }
-
     @Test
     fun beginEndSearch_setIsSearching_isSearchingAfterBegin() {
         activity.onBeginSearch("https://mastodon.foo.bar/@User")
diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
index 6264d86b95..85be92dc7b 100644
--- a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
@@ -12,6 +12,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
 import org.junit.Assert
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 import org.robolectric.annotation.Config

 @Config(sdk = [28])
@@ -308,4 +309,71 @@ class LinkHelperTest {
             Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})"))
         }
     }
+
+    @RunWith(Parameterized::class)
+    class UrlMatchingTests(private val url: String, private val expectedResult: Boolean) {
+        companion object {
+            @Parameterized.Parameters(name = "match_{0}")
+            @JvmStatic
+            fun data(): Iterable<Any> {
+                return listOf(
+                    arrayOf("https://mastodon.foo.bar/@User", true),
+                    arrayOf("http://mastodon.foo.bar/@abc123", true),
+                    arrayOf("https://mastodon.foo.bar/@user/345667890345678", true),
+                    arrayOf("https://mastodon.foo.bar/@user/3", true),
+                    arrayOf("https://pleroma.foo.bar/users/meh3223", true),
+                    arrayOf("https://pleroma.foo.bar/users/meh3223_bruh", true),
+                    arrayOf("https://pleroma.foo.bar/users/2345", true),
+                    arrayOf("https://pleroma.foo.bar/notice/9", true),
+                    arrayOf("https://pleroma.foo.bar/notice/9345678", true),
+                    arrayOf("https://pleroma.foo.bar/notice/wat", true),
+                    arrayOf("https://pleroma.foo.bar/notice/9qTHT2ANWUdXzENqC0", true),
+                    arrayOf("https://pleroma.foo.bar/objects/abcdef-123-abcd-9876543", true),
+                    arrayOf("https://misskey.foo.bar/notes/mew", true),
+                    arrayOf("https://misskey.foo.bar/notes/1421564653", true),
+                    arrayOf("https://misskey.foo.bar/notes/qwer615985ddf", true),
+                    arrayOf("https://friendica.foo.bar/profile/user", true),
+                    arrayOf("https://friendica.foo.bar/profile/uSeR", true),
+                    arrayOf("https://friendica.foo.bar/profile/user_user", true),
+                    arrayOf("https://friendica.foo.bar/profile/123", true),
+                    arrayOf("https://friendica.foo.bar/display/abcdef-123-abcd-9876543", true),
+                    arrayOf("https://google.com/", false),
+                    arrayOf("https://mastodon.foo.bar/@User?foo=bar", false),
+                    arrayOf("https://mastodon.foo.bar/@User#foo", false),
+                    arrayOf("http://mastodon.foo.bar/@", false),
+                    arrayOf("http://mastodon.foo.bar/@/345678", false),
+                    arrayOf("https://mastodon.foo.bar/@user/345667890345678/", false),
+                    arrayOf("https://mastodon.foo.bar/@user/3abce", false),
+                    arrayOf("https://pleroma.foo.bar/users/", false),
+                    arrayOf("https://pleroma.foo.bar/users/meow/", false),
+                    arrayOf("https://pleroma.foo.bar/users/@meow", false),
+                    arrayOf("https://pleroma.foo.bar/user/2345", false),
+                    arrayOf("https://pleroma.foo.bar/notices/123456", false),
+                    arrayOf("https://pleroma.foo.bar/notice/@neverhappen/", false),
+                    arrayOf("https://pleroma.foo.bar/object/abcdef-123-abcd-9876543", false),
+                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543", false),
+                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543/", false),
+                    arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd_9876543", false),
+                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd-9876543", false),
+                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd-9876543/", false),
+                    arrayOf("https://friendica.foo.bar/display/xabcdef-123-abcd_9876543", false),
+                    arrayOf("https://friendica.foo.bar/profile/@mew", false),
+                    arrayOf("https://friendica.foo.bar/profile/@mew/", false),
+                    arrayOf("https://misskey.foo.bar/notes/@nyan", false),
+                    arrayOf("https://misskey.foo.bar/notes/NYAN123", false),
+                    arrayOf("https://misskey.foo.bar/notes/meow123/", false),
+                    arrayOf("https://pixelfed.social/p/connyduck/391263492998670833", true),
+                    arrayOf("https://pixelfed.social/connyduck", true),
+                    arrayOf("https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2", true),
+                    arrayOf("https://gts.foo.bar/@goblin", true),
+                    arrayOf("https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5", true),
+                )
+            }
+        }
+
+        @Test
+        fun test() {
+            Assert.assertEquals(expectedResult, looksLikeMastodonUrl(url))
+        }
+    }
 }


Navigate to the Follow Requests page from notification

Fixes #2655

Pressing "<user> requested to follow you" android notification sends user to wrong UI
Pressing "<user> requested to follow you" android notification doesn't present the user with the "Follow Requests" UI. Instead, it presents it with the "Notifications" UI, but I'm not sure this is always the case, it could be another too.

This is really confusing from user point of view because there's nothing to do in the "Notifications" UI. Then the user needs to remember to go press the menu app button, and go to the "Follow Requests" UI, then remember the user which showed up in the notification and press the user in the list to finally be able to accept or not.

When the "<user> requested to follow you" android notification is pressed, the user shoul be presented either with:
A) The "Follow Requests" UI
B) The user's profile, where the "Accept Request" + "Follow" buttons should be available.

* * * *
- Tusky Version: 19.0
- Android Version: 11
- Android Device:
- Mastodon instance (if applicable): mastodon.social

- [X ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

Hi, just wanted to bump this issue back up, this issue makes me shake my head far too often, even after using Tusky for almost 3 years now. :/

./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.MainActivityTest"

diff --git a/app/build.gradle b/app/build.gradle
index bc60e65332..12699fb1da 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -146,6 +146,7 @@ dependencies {
     testImplementation libs.mockwebserver
     testImplementation libs.androidx.core.testing
     testImplementation libs.kotlinx.coroutines.test
+    testImplementation libs.androidx.work.testing

     androidTestImplementation libs.espresso.core
     androidTestImplementation libs.androidx.room.testing
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
index 80bcfde333..c00387b5f4 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
@@ -77,6 +77,7 @@ import com.keylesspalace.tusky.components.search.SearchActivity
 import com.keylesspalace.tusky.databinding.ActivityMainBinding
 import com.keylesspalace.tusky.db.AccountEntity
 import com.keylesspalace.tusky.entity.Account
+import com.keylesspalace.tusky.entity.Notification
 import com.keylesspalace.tusky.interfaces.AccountSelectionListener
 import com.keylesspalace.tusky.interfaces.ActionButtonActivity
 import com.keylesspalace.tusky.interfaces.ReselectableFragment
@@ -211,8 +212,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
                 val intent = DraftsActivity.newIntent(this)
                 startActivity(intent)
             } else if (accountRequested && savedInstanceState == null) {
-                // user clicked a notification, show notification tab
-                showNotificationTab = true
+                // user clicked a notification, show follow requests for type FOLLOW_REQUEST,
+                // otherwise show notification tab
+                if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
+                    val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
+                    startActivityWithSlideInAnimation(intent)
+                } else {
+                    showNotificationTab = true
+                }
             }
         }
         window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
index 615411384c..beac22aade 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
@@ -84,6 +84,8 @@ public class NotificationHelper {
      */
     public static final String ACCOUNT_ID = "account_id";

+    public static final String TYPE = "type";
+
     private static final String TAG = "NotificationHelper";

     public static final String REPLY_ACTION = "REPLY_ACTION";
@@ -268,6 +270,7 @@ public static void make(final Context context, Notification body, AccountEntity
     private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
         Intent summaryResultIntent = new Intent(context, MainActivity.class);
         summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
+        summaryResultIntent.putExtra(TYPE, body.getType().name());
         TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
         summaryStackBuilder.addParentStack(MainActivity.class);
         summaryStackBuilder.addNextIntent(summaryResultIntent);
@@ -278,6 +281,7 @@ private static NotificationCompat.Builder newNotification(Context context, Notif
         // we have to switch account here
         Intent eventResultIntent = new Intent(context, MainActivity.class);
         eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
+        eventResultIntent.putExtra(TYPE, body.getType().name());
         TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
         eventStackBuilder.addParentStack(MainActivity.class);
         eventStackBuilder.addNextIntent(eventResultIntent);
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0b71df70f7..ad7bba3a04 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -82,6 +82,7 @@ androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefre
 androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
 androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" }
 androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
+androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
 autodispose-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" }
 autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" }
 bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" }

diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt
new file mode 100644
index 0000000000..1414245551
--- /dev/null
+++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt
@@ -0,0 +1,130 @@
+package com.keylesspalace.tusky
+
+import android.app.Activity
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.viewpager2.widget.ViewPager2
+import androidx.work.testing.WorkManagerTestInitHelper
+import at.connyduck.calladapter.networkresult.NetworkResult
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.components.notifications.NotificationHelper
+import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.entity.Account
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.TimelineAccount
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.util.concurrent.BackgroundExecutor.runInBackground
+import org.robolectric.annotation.Config
+import java.util.Date
+
+@Config(sdk = [28])
+@RunWith(AndroidJUnit4::class)
+class MainActivityTest {
+
+    private val context = InstrumentationRegistry.getInstrumentation().targetContext
+    private val account = Account(
+        id = "1",
+        localUsername = "",
+        username = "",
+        displayName = "",
+        createdAt = Date(),
+        note = "",
+        url = "",
+        avatar = "",
+        header = "",
+    )
+    private val accountEntity = AccountEntity(
+        id = 1,
+        domain = "test.domain",
+        accessToken = "fakeToken",
+        clientId = "fakeId",
+        clientSecret = "fakeSecret",
+        isActive = true
+    )
+
+    @Before
+    fun setup() {
+        WorkManagerTestInitHelper.initializeTestWorkManager(context)
+    }
+
+    @Test
+    fun `clicking notification of type FOLLOW shows notification tab`() {
+        val intent = showNotification(Notification.Type.FOLLOW)
+
+        val activity = startMainActivity(intent)
+        val currentTab = activity.findViewById<ViewPager2>(R.id.viewPager).currentItem
+
+        val notificationTab = defaultTabs().indexOfFirst { it.id == NOTIFICATIONS }
+
+        assertEquals(currentTab, notificationTab)
+    }
+
+    @Test
+    fun `clicking notification of type FOLLOW_REQUEST shows follow requests`() {
+        val intent = showNotification(Notification.Type.FOLLOW_REQUEST)
+
+        val activity = startMainActivity(intent)
+        val nextActivity = shadowOf(activity).peekNextStartedActivity()
+
+        assertNotNull(nextActivity)
+        assertEquals(ComponentName(context, AccountListActivity::class.java.name), nextActivity.component)
+        assertEquals(AccountListActivity.Type.FOLLOW_REQUESTS, nextActivity.getSerializableExtra("type"))
+    }
+
+    private fun showNotification(type: Notification.Type): Intent {
+        val notificationManager = context.getSystemService(NotificationManager::class.java)
+        val shadowNotificationManager = shadowOf(notificationManager)
+
+        NotificationHelper.createNotificationChannelsForAccount(accountEntity, context)
+
+        runInBackground {
+            NotificationHelper.make(
+                context,
+                Notification(
+                    type = type,
+                    id = "id",
+                    account = TimelineAccount(
+                        id = "1",
+                        localUsername = "connyduck",
+                        username = "connyduck@mastodon.example",
+                        displayName = "Conny Duck",
+                        url = "https://mastodon.example/@ConnyDuck",
+                        avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
+                    ),
+                    status = null
+                ),
+                accountEntity,
+                true
+            )
+        }
+
+        val notification = shadowNotificationManager.allNotifications.first()
+        return shadowOf(notification.contentIntent).savedIntent
+    }
+
+    private fun startMainActivity(intent: Intent): Activity {
+        val controller = Robolectric.buildActivity(MainActivity::class.java, intent)
+        val activity = controller.get()
+        activity.eventHub = EventHub()
+        activity.accountManager = mock {
+            on { activeAccount } doReturn accountEntity
+        }
+        activity.mastodonApi = mock {
+            onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
+            onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())
+        }
+        controller.create().start()
+        return activity
+    }
+}


Parse html tags out of status content before applying filters

Fixes #2660

Filters with Hashtags not working

Filters with Hashtags not working
I think we had this issue in the past, but here it is again. :)

A filter for a hastag:

![image](https://user-images.githubusercontent.com/42511384/183861683-31fb6792-6346-46dd-a9e9-cf9dc172388c.png)

It works in web:

![image](https://user-images.githubusercontent.com/42511384/183861808-7196af5e-7734-49d6-b64a-557fb2abfb42.png)

but not in the app:

![image](https://user-images.githubusercontent.com/42511384/183862106-4d623a37-4e06-4851-bf80-5d67c1b555e1.png)

Things I've tried:

- Restarting the phone
- Restarting the app (force stop & cache delete)
- Deleting and recreating the filter
- Turning the "whole word" option on and off

* * * *
- Tusky Version: Tusky 19.0
- Android Version: 12
- Android Device: Samsung S21
- Mastodon instance (if applicable):

- [X] I searched or browsed the repo’s other (open) issues to ensure this is not a duplicate.

./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.FilterTest"

diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt
index db47beaf08..16a439d950 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt
@@ -3,6 +3,7 @@ package com.keylesspalace.tusky.network
 import android.text.TextUtils
 import com.keylesspalace.tusky.entity.Filter
 import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.util.parseAsMastodonHtml
 import java.util.Date
 import java.util.regex.Pattern
 import javax.inject.Inject
@@ -34,7 +35,7 @@ class FilterModel @Inject constructor() {
             .mapNotNull { it.description }

         return (
-            matcher.reset(status.actionableStatus.content).find() ||
+            matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() ||
                 (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
                 (
                     attachmentsDescriptions.isNotEmpty() &&

diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
index b28f6c9a07..b8ca181e9f 100644
--- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
@@ -53,6 +53,14 @@ class FilterTest {
                 irreversible = false,
                 wholeWord = true
             ),
+            Filter(
+                id = "123",
+                phrase = "#hashtag",
+                context = listOf(Filter.HOME),
+                expiresAt = null,
+                irreversible = false,
+                wholeWord = true
+            ),
             Filter(
                 id = "123",
                 phrase = "expired",
@@ -69,6 +77,14 @@ class FilterTest {
                 irreversible = false,
                 wholeWord = true
             ),
+            Filter(
+                id = "123",
+                phrase = "href",
+                context = listOf(Filter.HOME),
+                expiresAt = null,
+                irreversible = false,
+                wholeWord = false
+            ),
         )

         filterModel.initWithFilters(filters)
@@ -166,6 +182,33 @@ class FilterTest {
         )
     }

+    @Test
+    fun shouldFilterHashtags() {
+        assertTrue(
+            filterModel.shouldFilterStatus(
+                mockStatus(content = "#hashtag one two three")
+            )
+        )
+    }
+
+    @Test
+    fun shouldFilterHashtags_whenContentIsMarkedUp() {
+        assertTrue(
+            filterModel.shouldFilterStatus(
+                mockStatus(content = "
#hashtagone two three
")
+            )
+        )
+    }
+
+    @Test
+    fun shouldNotFilterHtmlAttributes() {
+        assertFalse(
+            filterModel.shouldFilterStatus(
+                mockStatus(content = "
https://foo.bar/ one two three
")
+            )
+        )
+    }
+
     @Test
     fun shouldNotFilter_whenFilterIsExpired() {
         assertFalse(


Improve the Search Logic

Fixes #3132.

While the copilot pull request is a good approach, this one includes more sophisticated unit tests together with `BookFactory` adjustments to search for the new fields `genre`, `narrator`, `series` and `part`. The production code is basically the same.

@PaulWoitaschek
A 2 minute review should already be enough to decide which one you'd prefer :-)

Search seems to rely on a strange query preparation, that does not seem right?!
### Overview

I stumbled over this while trying to search for the new `series` and `part` fields recently added, e.g `Benjamin Blümchen 124`

When searching a book, the search query is rewritten prepared, to use the `MATCH` keyword in an sqlite full text search using `FTS4` here:

https://github.com/PaulWoitaschek/Voice/blob/main/core/search/src/main/kotlin/voice/core/search/BookSearch.kt

This is the relevant code snippet:

```kotlin
  suspend fun search(query: String): List<Book> {
    return dao.search(
      buildString {
        append("\"")
        append('*')
        append(query.trim().replace("\"", "\"\""))
        append('*')
        append("\"")
      },
    ).mapNotNull { repo.get(it) }
  }
```

So searching for `Harry Potter 1` will become `"*Harry Potter 1*"`.

After doing a bit of research, I think a few things are strange:

- Enclosing the whole query in `"` (double quotes) results in searching for an `exact phrase` query, which should not work for wildcards at all (I still wonder why this still seems to work somehow, probably the asterisks and quotes are just ignored?)
- Prepending and appending an asterisk (`*`) seems strange, since sqlite only supports prefix matching (e.g. `hel* worl*`) - a leading asterisk `*arry *otter` seems not to be supported at all (is this an Android specific extension?)
- Replacing `"` (double quotes) within the query would search for the real existence of double quotes in the target string, which seems to have the same effect than removing it completely, because it does not even seem to be stored in the index

So I think this could be changed to something like this:

```kotlin
  suspend fun search(query: String): List<Book> {
    val searchString = buildString {
      append(
        query.trim()
          // replace all special chars except umlauts and other language specific letters
          .replace("[^\\p{L}0-9\\s]".toRegex(), " ")
          // replace all whitespaces with *
          .split("\\s+".toRegex()).joinToString("*")
      )
      append('*')
    }
    val results = dao.search(searchString)

    return results.mapNotNull { repo.get(it) }
  }
```

Now searching for `Harry Potter 1` will become `Harry*Potter*1*`.

`the wise man's fear 2.1` becomes `the*wise*man*s*fear*2*1*`, which should pretty likely result in what would be expected...

Could you elaborate on your decision on how to prepare the query for using it in the MATCH part?

### How to reproduce

Search for `Harry Potter 1`, when the test data contains

### Version

Latest

### Android Version

13

### Device Model

Pixel 4a

Tbh I don't remember. I also recently looked at the implementation and was rather puzzled.

Feel free to drop a few test cases and improve the implementation!

./gradlew :core:search:testDebugUnitTest --tests "voice.core.search.BookSearchTest"

diff --git a/core/search/src/main/kotlin/voice/core/search/BookSearch.kt b/core/search/src/main/kotlin/voice/core/search/BookSearch.kt
index 8bca83d616..cd1dd62cac 100644
--- a/core/search/src/main/kotlin/voice/core/search/BookSearch.kt
+++ b/core/search/src/main/kotlin/voice/core/search/BookSearch.kt
@@ -4,22 +4,25 @@ import dev.zacsweers.metro.Inject
 import voice.core.data.Book
 import voice.core.data.repo.BookRepository
 import voice.core.data.repo.internals.dao.BookContentDao
+import voice.core.logging.api.Logger

 @Inject
 class BookSearch(
   private val dao: BookContentDao,
   private val repo: BookRepository,
 ) {
-
   suspend fun search(query: String): List<Book> {
-    return dao.search(
-      buildString {
-        append("\"")
-        append('*')
-        append(query.trim().replace("\"", "\"\""))
-        append('*')
-        append("\"")
-      },
-    ).mapNotNull { repo.get(it) }
+    val matchQuery = buildString {
+      append(
+        query.trim()
+          // replace all special chars except umlauts and other language specific letters
+          .replace("[^\\p{L}0-9\\s]".toRegex(), " ")
+          // replace all whitespaces with *
+          .split("\\s+".toRegex()).filter { it.isNotEmpty() }.joinToString("*"),
+      )
+      append('*')
+    }
+    Logger.d("search with MATCH: $matchQuery")
+    return dao.search(matchQuery).mapNotNull { repo.get(it) }
   }
 }

diff --git a/core/search/src/test/kotlin/voice/core/search/BookFactory.kt b/core/search/src/test/kotlin/voice/core/search/BookFactory.kt
index 965e02226c..818c8ec3c5 100644
--- a/core/search/src/test/kotlin/voice/core/search/BookFactory.kt
+++ b/core/search/src/test/kotlin/voice/core/search/BookFactory.kt
@@ -14,6 +14,10 @@ fun book(
   currentChapter: ChapterId = chapters.first().id,
   name: String = UUID.randomUUID().toString(),
   author: String? = UUID.randomUUID().toString(),
+  genre: String? = "Genre",
+  narrator: String? = "Narrator",
+  series: String? = null,
+  part: String? = null,
 ): Book {
   return Book(
     content = BookContent(
@@ -30,10 +34,10 @@ fun book(
       skipSilence = false,
       id = BookId(UUID.randomUUID().toString()),
       gain = 0F,
-      genre = null,
-      narrator = null,
-      series = null,
-      part = null,
+      genre = genre,
+      narrator = narrator,
+      series = series,
+      part = part,
     ),
     chapters = chapters,
   )
diff --git a/core/search/src/test/kotlin/voice/core/search/BookSearchTest.kt b/core/search/src/test/kotlin/voice/core/search/BookSearchTest.kt
index a82b7ea29b..be8b93e95d 100644
--- a/core/search/src/test/kotlin/voice/core/search/BookSearchTest.kt
+++ b/core/search/src/test/kotlin/voice/core/search/BookSearchTest.kt
@@ -34,6 +34,40 @@ class BookSearchTest {
     author = "Jimi Hendrix",
   )

+  private val harryPotter1 = book(
+    name = "Harry Potter and the Philosopher's Stone",
+    author = "J. K. Rowling",
+    genre = "Fantasy",
+    narrator = "Stephen Fry",
+    series = "Harry Potter",
+    part = "1",
+  )
+
+  private val harryPotter2 = book(
+    name = "Harry Potter and the Chamber of Secrets",
+    author = "J. K. Rowling",
+    genre = "Fantasy",
+    narrator = "Stephen Fry",
+    series = "Harry Potter",
+    part = "2",
+  )
+  private val kingkiller1 = book(
+    name = "The Name of the Wind",
+    author = "Patrick Rothfuss",
+    genre = "Fantasy",
+    narrator = "Rupert Degas",
+    series = "Kingkiller Chronicle",
+    part = "1",
+  )
+  private val kingkiller25 = book(
+    name = "The Slow Regard of Silent Things",
+    author = "Patrick Rothfuss",
+    genre = "Fantasy",
+    narrator = "Patrick Rothfuss",
+    series = "Kingkiller Chronicle",
+    part = "2.5",
+  )
+
   @Test
   fun `search complete name`() = test {
     expectSearchResult("Die Ermordung des Commendatore", commendatore)
@@ -78,6 +112,19 @@ class BookSearchTest {
     search("-*\"--*\"\"")
   }

+  @Test
+  fun `search for narrator, series and part`() = test {
+    expectSearchResult("Harry Potter 1", harryPotter1)
+    expectSearchResult("harry potter 1", harryPotter1)
+    expectSearchResult("harry potter", harryPotter1, harryPotter2)
+    expectSearchResult("kingkiller 1", kingkiller1)
+    expectSearchResult("rupert degas 1", kingkiller1)
+    expectSearchResult("kingkiller 2.5", kingkiller25)
+    expectSearchResult("slow regard 2.5", kingkiller25)
+    expectSearchResult("rothfuss 2.5", kingkiller25)
+    expectSearchResult("kingkiller", kingkiller1, kingkiller25)
+  }
+
   private fun test(run: suspend TestBase.() -> Unit) {
     val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDb::class.java)
       .build()
@@ -103,11 +150,19 @@ class BookSearchTest {
       addBook(commendatore)
       addBook(watchtower)
       addBook(unicorns)
+      addBook(harryPotter1)
+      addBook(harryPotter2)
+      addBook(kingkiller1)
+      addBook(kingkiller25)

       // this ensures that inactive books are never accounted for
       addBook(commendatore.withNewIdAndInactive())
       addBook(watchtower.withNewIdAndInactive())
       addBook(unicorns.withNewIdAndInactive())
+      addBook(harryPotter1.withNewIdAndInactive())
+      addBook(harryPotter2.withNewIdAndInactive())
+      addBook(kingkiller1.withNewIdAndInactive())
+      addBook(kingkiller25.withNewIdAndInactive())

       testBase.run()
       db.close()


Fix the sleep timer

Fixes #3110

Sleep timer crashes after restarting

Sleep timer crashes after restarting
### Overview

The sleep timer crashes after restarting by shaking the phone.

I've tested with and without the setting to automatically enable sleep timer. In both cases, the timer (set to 1 minute) crashed with about 35 seconds (+- 2 sec) left and the audiobook kept playing indefinitely.

### How to reproduce

1. Set the sleep timer to 1 minute and start playing
2. Lock the screen
3. After the minute is over, shake the phone to start the 1 minute timer again
4. After 1 minute, the audiobook is still playing

### Version

8.7.0

### Android Version

Android 16

### Device Model

Pixel 7a

./gradlew :core:sleeptimer:impl:testDebugUnitTest --tests "voice.core.sleeptimer.impl.SleepTimerImplTest"

diff --git a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetector.kt b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetector.kt
index 075f203300..b1ffcb22af 100644
--- a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetector.kt
+++ b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetector.kt
@@ -1,32 +1,9 @@
 package voice.core.sleeptimer

-import android.content.Context
-import android.hardware.SensorManager
-import dev.zacsweers.metro.Inject
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlin.coroutines.resume
-import com.squareup.seismic.ShakeDetector as SeismicShakeDetector
-
-@Inject
-class ShakeDetector(private val context: Context) {
+interface ShakeDetector {

   /**
    * This function returns once a shake was detected
    */
-  suspend fun detect() {
-    suspendCancellableCoroutine { cont ->
-      val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager?
-        ?: return@suspendCancellableCoroutine
-      val listener = SeismicShakeDetector.Listener {
-        if (!cont.isCompleted) {
-          cont.resume(Unit)
-        }
-      }
-      val shakeDetector = SeismicShakeDetector(listener)
-      shakeDetector.start(sensorManager, SensorManager.SENSOR_DELAY_GAME)
-      cont.invokeOnCancellation {
-        shakeDetector.stop()
-      }
-    }
-  }
+  suspend fun detect()
 }
diff --git a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetectorImpl.kt b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetectorImpl.kt
new file mode 100644
index 0000000000..95df7c2d1c
--- /dev/null
+++ b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/ShakeDetectorImpl.kt
@@ -0,0 +1,32 @@
+package voice.core.sleeptimer
+
+import android.content.Context
+import android.hardware.SensorManager
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import dev.zacsweers.metro.Inject
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import com.squareup.seismic.ShakeDetector as SeismicShakeDetector
+
+@ContributesBinding(AppScope::class)
+@Inject
+class ShakeDetectorImpl(private val context: Context) : ShakeDetector {
+
+  override suspend fun detect() {
+    suspendCancellableCoroutine { cont ->
+      val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager?
+        ?: return@suspendCancellableCoroutine
+      val listener = SeismicShakeDetector.Listener {
+        if (!cont.isCompleted) {
+          cont.resume(Unit)
+        }
+      }
+      val shakeDetector = SeismicShakeDetector(listener)
+      shakeDetector.start(sensorManager, SensorManager.SENSOR_DELAY_GAME)
+      cont.invokeOnCancellation {
+        shakeDetector.stop()
+      }
+    }
+  }
+}
diff --git a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt
index 1ab8074c0a..6d00b2ff96 100644
--- a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt
+++ b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt
@@ -71,7 +71,7 @@ class SleepTimerImpl(
     playerController.setVolume(1F)
   }

-  private suspend fun startCountdown(duration: Duration) {
+  private tailrec suspend fun startCountdown(duration: Duration) {
     Logger.d("startCountdown(duration=$duration)")
     var left = duration
     _state.value = SleepTimerState.Enabled.WithDuration(left)
@@ -95,15 +95,21 @@ class SleepTimerImpl(

     playerController.pauseWithRewind(fadeOutDuration)

-    val shakeToResetTime = 30.seconds
-    Logger.d("Waiting $shakeToResetTime for shake...")
-    withTimeoutOrNull(shakeToResetTime) {
-      shakeDetector.detect()
+    val shakeDetected = detectShakeWithTimeout()
+    playerController.setVolume(1F)
+    if (shakeDetected) {
       Logger.i("Shake detected, resetting timer")
       playerController.play()
       startCountdown(duration)
     }
-    playerController.setVolume(1F)
+  }
+
+  private suspend fun detectShakeWithTimeout(): Boolean {
+    Logger.d("Waiting $SHAKE_TO_RESET_TIME for shake...")
+    return withTimeoutOrNull(SHAKE_TO_RESET_TIME) {
+      shakeDetector.detect()
+      true
+    } ?: false
   }

   private fun updateVolume(
@@ -122,4 +128,8 @@ class SleepTimerImpl(
       Logger.i("Playback resumed.")
     }
   }
+
+  internal companion object {
+    val SHAKE_TO_RESET_TIME = 30.seconds
+  }
 }

diff --git a/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt b/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt
index 436f71cfc4..21ee4fbc22 100644
--- a/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt
+++ b/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt
@@ -6,16 +6,16 @@ import io.kotest.matchers.collections.shouldEndWith
 import io.kotest.matchers.collections.shouldNotBeEmpty
 import io.kotest.matchers.shouldBe
 import io.mockk.Runs
-import io.mockk.coEvery
 import io.mockk.coVerify
 import io.mockk.every
 import io.mockk.just
 import io.mockk.mockk
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.suspendCancellableCoroutine
+import io.mockk.verify
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.yield
 import org.junit.BeforeClass
@@ -33,17 +33,23 @@ import voice.core.sleeptimer.SleepTimerMode
 import voice.core.sleeptimer.SleepTimerState
 import kotlin.time.Duration.Companion.seconds

+private class TestShakeDetector : ShakeDetector {
+  private val shakes = Channel<Unit>(capacity = Channel.UNLIMITED)
+  override suspend fun detect() {
+    shakes.receive()
+  }
+
+  fun shake() {
+    shakes.trySend(Unit)
+  }
+}
+
 class SleepTimerImplTest {

   private val playStateManager = PlayStateManager().apply {
     playState = PlayStateManager.PlayState.Playing
   }
-  private val shakeDetector = mockk<ShakeDetector> {
-    coEvery { detect() } coAnswers {
-      delay(30.seconds)
-    }
-  }
-
+  private val shakeDetector = TestShakeDetector()
   private val sleepTimerPreferenceStore = MemoryDataStore(SleepTimerPreference.Default)
   private val setVolumeSlots = mutableListOf<Float>()
   private val playerController = mockk<PlayerController> {
@@ -51,6 +57,11 @@ class SleepTimerImplTest {
     every { pauseWithRewind(any()) } answers {
       playStateManager.playState = PlayStateManager.PlayState.Paused
     }
+    every {
+      play()
+    } answers {
+      playStateManager.playState = PlayStateManager.PlayState.Playing
+    }
   }

   private val fadeOutStore = MemoryDataStore(2.seconds)
@@ -78,10 +89,6 @@ class SleepTimerImplTest {

   @Test
   fun `enable with fixed duration eventually disables and pauses playback`() = testScope.runTest {
-    coEvery { shakeDetector.detect() } coAnswers {
-      suspendCancellableCoroutine { } // never completes
-    }
-
     sleepTimer.enable(SleepTimerMode.TimedWithDuration(1.seconds))

     advanceTimeBy(2.seconds)
@@ -130,6 +137,34 @@ class SleepTimerImplTest {
     setVolumeSlots.shouldEndWith(1f)
   }

+  @Test
+  fun shake_does_not_cancel_second_countdown_after_window() = testScope.runTest {
+    // Use a LONG duration so we can observe behavior across the 30s window
+    val longDuration = SleepTimerImpl.SHAKE_TO_RESET_TIME * 2
+
+    sleepTimer.enable(SleepTimerMode.TimedWithDuration(longDuration))
+
+    // 1) Let the first countdown finish and enter the shake window
+    advanceTimeBy(longDuration + 1.seconds)
+    runCurrent()
+    coVerify(exactly = 1) { playerController.pauseWithRewind(any()) }
+    sleepTimer.state.value shouldBe SleepTimerState.Disabled
+
+    // 2) Trigger the shake → a new countdown should start independently of the old timeout
+    shakeDetector.shake()
+    runCurrent()
+    verify(exactly = 1) { playerController.play() }
+    sleepTimer.state.value shouldBe SleepTimerState.Enabled.WithDuration(longDuration)
+
+    // 3) Advance past the original 30s shake window and allow the second countdown to finish
+    advanceTimeBy(SleepTimerImpl.SHAKE_TO_RESET_TIME + longDuration + 2.seconds)
+    runCurrent()
+
+    // The second countdown should complete normally
+    coVerify(exactly = 2) { playerController.pauseWithRewind(any()) }
+    sleepTimer.state.value shouldBe SleepTimerState.Disabled
+  }
+
   companion object {

     @BeforeClass


fix: Parse mpeg4 chapter tracks correctly

Fixes #2840

newest version has killed chapters for my entire library
### Overview

User for ~3 years, since the new version the app hasn't been identifying chapters for any of my m4b files, this includes files that previously worked fine on older versions, I've replicated the error on 2x pixel phones both running Graphene OS.  This has only been present since the new version that changes how chapters are parsed.

### How to reproduce

Open the app
Tap any previously added book or add a new one
Playback starts
No chapter select dropdown present, starts playing back as a single hours-long audio file

### Version

8.4.2 - f droid main repository

### Android Version

Graphene OS 2025051900

### Device Model

Pixel 8a; Pixel 7

Can confirm that rolling back to 8.3.2 resolves the issue completely
Thanks! Could you send me one file to my mail which previously had chapters but now doesn't?
I'm pinning this issue and will stop the rollout until this is fixed.
If someone could send files which reproduce this issue, that would be really helpful!
I face the same issue with Matroska Containers and can confirm that it is fixed when rolling back to 8.3.2. In addition, the cover art is also not parsed on the latest version, but on 8.3.2 (I don’t know if this is related).
I’ll send you an e-mail with a test file you can use for reproducing this.
On my end, I needed to clear the app data when updating from 8.3.2 to 8.5.0 to reproduce the issue, as it otherwise still used the cached data from the successful metadata import on 8.3.2.
Thank you. I could reproduce it with the MKA chapters. If somebody has other media, especially M4A and m4b files with non-recognized chapters, please send them to me.
It wouldnt let me upload a m4b so i threw .txt on the end of it. The chapters can be detected in vlc but not voice.

[Itsa_test.m4b.txt](https://github.com/user-attachments/files/20557496/Itsa_test.m4b.txt)
And here is my function for creating the audiobook in case its helpful. (that produced that m4b)

```python
def create_audiobook_m4b(chapters_audio_data, metadata, cover_image_path=None, output_path="audiobook.m4b", chapter_pause_duration_s=1.0, bitrate="128k"):
    """
    Creates an M4B audiobook file with chapters, metadata, and optional cover image,
    mimicking epub2tts.py's ffmpeg strategy for better chapter compatibility.
    chapters_audio_data is a list of tuples: (title, samplerate, numpy_audio_array)
    """
    with tempfile.TemporaryDirectory() as temp_dir_name:
        temp_dir = Path(temp_dir_name)

        # Write chapter audio files (as WAV) to temporary directory
        chapter_raw_audio_files = []
        # Ensure consistent samplerate for concatenation, resample if necessary.
        # For simplicity, this example assumes all generated audio has the same samplerate (model.sr).
        # If not, resampling would be needed here before writing to file or during ffmpeg concat.
        target_sr = None
        for i, (title, sr, audio_array) in enumerate(chapters_audio_data):
            if audio_array.size == 0:
                print(f"Skipping empty audio for chapter: {title}")
                continue
            if target_sr is None:
                target_sr = sr
            elif target_sr != sr:
                # This part would require resampling. For now, we assume consistency.
                print(f"Warning: Samplerate mismatch for chapter {title}. Expected {target_sr}, got {sr}. Playback issues may occur.")

            chapter_file = temp_dir / f"chapter_{i:03d}.wav"
            sf.write(chapter_file, audio_array, sr)
            chapter_raw_audio_files.append(chapter_file)

        if not chapter_raw_audio_files:
            raise ValueError("No valid audio chapters to process for the audiobook.")

        # Create concat list file including silences
        concat_list_file = temp_dir / "concat_list.txt"
        with open(concat_list_file, 'w', encoding='utf-8') as f:
            for i, chap_file in enumerate(chapter_raw_audio_files):
                # ffmpeg needs relative paths from the perspective of the concat file, or absolute paths.
                # Using resolve() for absolute paths is safer.
                f.write(f"file '{chap_file.resolve()}'\n")
                if i < len(chapter_raw_audio_files) - 1 and chapter_pause_duration_s > 0:
                    silence_file = temp_dir / f"silence_{i:03d}.wav"
                    # Ensure target_sr is defined; it should be from the first valid chapter.
                    pause_samples = int(target_sr * chapter_pause_duration_s)
                    silent_audio = np.zeros(pause_samples, dtype=np.float32)
                    sf.write(silence_file, silent_audio, target_sr)
                    f.write(f"file '{silence_file.resolve()}'\n")

        # --- First FFmpeg Pass: Create intermediate M4A audio file ---
        intermediate_m4a_file = temp_dir / "intermediate_audio.m4a"
        concat_cmd = [
            'ffmpeg', '-y',
            '-f', 'concat', '-safe', '0', '-i', str(concat_list_file),
            '-codec:a', 'aac',  # Standard AAC codec
            '-b:a', bitrate,    # Use the provided bitrate
            '-f', 'ipod',       # CRUCIAL: Specify ipod container format for M4A
            str(intermediate_m4a_file)
        ]
        print(f"Running FFmpeg (Pass 1 - Audio Concat): {' '.join(concat_cmd)}")
        try:
            process_result = subprocess.run(concat_cmd, check=True, capture_output=True, text=True)
            if process_result.stderr:
                print("FFMPEG (Pass 1) stderr:", process_result.stderr)
        except subprocess.CalledProcessError as e:
            print("Error during FFmpeg Pass 1 (Audio Concat):")
            print("Command:", ' '.join(e.cmd))
            print("Return code:", e.returncode)
            print("Stdout:", e.stdout)
            print("Stderr:", e.stderr)
            raise

        # --- Create FFMETADATAFILE ---
        ffmetadata_file_path = temp_dir / "FFMETADATAFILE.txt" # Exact name as in epub2tts for consistency
        with open(ffmetadata_file_path, 'w', encoding='utf-8') as f:
            f.write(';FFMETADATA1\n')
            # Write main metadata (use uppercase for common tags like epub2tts)
            # Prefer 'title' from metadata, fallback to 'album' or a default.
            album_title = metadata.get('title', metadata.get('album', 'Untitled Audiobook'))
            f.write(f"TITLE={metadata.get('title', 'Untitled Audiobook')}\n") # Specific Title tag
            f.write(f"ALBUM={album_title}\n") # Album tag
            f.write(f"ARTIST={metadata.get('author', 'Unknown Author')}\n") # Artist tag
            if metadata.get('genre'):
                f.write(f"GENRE={metadata.get('genre')}\n")
            if metadata.get('year'):
                f.write(f"DATE={metadata.get('year')}\n") # DATE is more standard than YEAR for ffmpeg metadata
            # epub2tts uses DESCRIPTION, your script had 'comment'
            description_text = metadata.get('comment', 'Made with Chatterbox TTS Pro')
            if 'narrator' in metadata:
                 description_text += f" Narrated by {metadata['narrator']}."
            f.write(f"DESCRIPTION={description_text}\n")

            # Write chapter metadata
            current_time_ms = 0
            # Re-calculate durations based on the actual files that went into concat,
            # as ffmpeg will use these to build the timeline.
            # This means we need to sum durations of chapter files and silence files.
            # Note: get_duration would be a helper function if we didn't have the audio arrays directly.
            # Since we have chapters_audio_data, we can use that.
            for i, (title, sr, audio_array) in enumerate(chapters_audio_data):
                if audio_array.size == 0:
                    continue

                duration_ms = int((len(audio_array) / sr) * 1000)
                end_time_ms = current_time_ms + duration_ms

                f.write('\n[CHAPTER]\n')
                f.write('TIMEBASE=1/1000\n') # Millisecond timebase
                f.write(f'START={current_time_ms}\n')
                f.write(f'END={end_time_ms}\n')
                f.write(f'title={title if title else f"Chapter {i+1}"}\n')

                current_time_ms = end_time_ms
                if i < len(chapters_audio_data) - 1: # Add pause duration if not the last chapter
                    current_time_ms += int(chapter_pause_duration_s * 1000)

        # --- Second FFmpeg Pass: Combine M4A with Metadata and Cover ---
        final_ffmpeg_cmd = [
            'ffmpeg', '-y',
            '-i', str(intermediate_m4a_file),   # Input 0: intermediate M4A audio
            '-i', str(ffmetadata_file_path),    # Input 1: FFMETADATAFILE
            '-map_metadata', '1',                # Map metadata from input 1 (FFMETADATAFILE)
            '-codec', 'copy',                    # Copy audio and video (if cover added) streams
            '-f', 'ipod',                        # CRUCIAL: Specify ipod container for final M4B
        ]

        # Add cover image if available, mapping it as the third input
        if cover_image_path and Path(cover_image_path).exists():
            final_ffmpeg_cmd.extend([
                '-i', str(Path(cover_image_path).resolve()), # Input 2: Cover image
                '-map', '0:a',                   # Map audio from input 0
                '-map', '2:v',                   # Map video (cover) from input 2
                '-disposition:v', 'attached_pic', # Mark as cover art
                 # Set common cover image metadata (optional, but good practice)
                '-metadata:s:v', 'title="Album cover"',
                '-metadata:s:v', 'comment="Cover (front)"'
            ])
        else:
            # If no cover, just map the audio from input 0
            final_ffmpeg_cmd.extend(['-map', '0:a'])

        final_ffmpeg_cmd.append(str(Path(output_path).resolve()))

        print(f"Running FFmpeg (Pass 2 - Final M4B): {' '.join(final_ffmpeg_cmd)}")
        try:
            process_result = subprocess.run(final_ffmpeg_cmd, check=True, capture_output=True, text=True)
            if process_result.stderr:
                print("FFMPEG (Pass 2) stderr:", process_result.stderr)
        except subprocess.CalledProcessError as e:
            print("Error during FFmpeg Pass 2 (Final M4B creation):")
            print("Command:", ' '.join(e.cmd))
            print("Return code:", e.returncode)
            print("Stdout:", e.stdout)
            print("Stderr:", e.stderr)
            raise

    return str(Path(output_path).resolve())
```
@Lastofthefirst

Which version of voice did you test this with?
8.4.3
@Lastofthefirst
Can you confirm that:
- if you add the file as a single-file
  - it shows no chapters
- if you add it as part of a folder
  - it shows chapters

./gradlew :scanner:testDebugUnitTest --tests "voice.app.scanner.MediaAnalyzerTest"

diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt
deleted file mode 100644
index 3f7b2f9997..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package voice.app.scanner.mp4
-
-import androidx.media3.common.DataReader
-import androidx.media3.common.Format
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.extractor.TrackOutput
-import androidx.media3.extractor.text.CueDecoder
-import voice.data.MarkData
-import java.io.ByteArrayOutputStream
-import java.util.concurrent.TimeUnit
-
-internal class ChapterCueProcessor(private val outputChapters: MutableList<MarkData>) : TrackOutput {
-
-  private val sampleDataBuffer = ByteArrayOutputStream()
-
-  override fun format(format: Format) {
-    // Not needed for chapter extraction
-  }
-
-  override fun sampleData(
-    input: DataReader,
-    length: Int,
-    allowEndOfInput: Boolean,
-    sampleDataPart: Int,
-  ): Int {
-    if (length == 0) return 0
-
-    val buffer = ByteArray(length)
-    val bytesRead = input.read(buffer, 0, length)
-
-    if (bytesRead > 0) {
-      sampleDataBuffer.write(buffer, 0, bytesRead)
-    }
-
-    return bytesRead
-  }
-
-  override fun sampleData(
-    data: ParsableByteArray,
-    length: Int,
-    sampleDataPart: Int,
-  ) {
-    if (length == 0) return
-
-    val currentPosition = data.position
-    sampleDataBuffer.write(data.data, currentPosition, length)
-    data.skipBytes(length)
-  }
-
-  override fun sampleMetadata(
-    timeUs: Long,
-    flags: Int,
-    size: Int,
-    offset: Int,
-    cryptoData: TrackOutput.CryptoData?,
-  ) {
-    val sampleBytes = sampleDataBuffer.toByteArray()
-    val effectiveOffset = sampleBytes.size - offset - size
-
-    val cuesWithTiming = CueDecoder().decode(timeUs, sampleBytes, effectiveOffset, size)
-
-    val cue = cuesWithTiming.cues.firstOrNull()
-    if (cue != null) {
-      val chapterMark = MarkData(
-        startMs = TimeUnit.MICROSECONDS.toMillis(timeUs),
-        name = cue.text?.toString() ?: "",
-      )
-      outputChapters.add(chapterMark)
-    }
-
-    sampleDataBuffer.reset()
-  }
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt
deleted file mode 100644
index 2abe1961d6..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package voice.app.scanner.mp4
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.PositionHolder
-import androidx.media3.extractor.mp4.Mp4Extractor
-import androidx.media3.extractor.text.DefaultSubtitleParserFactory
-import voice.data.MarkData
-import voice.logging.core.Logger
-import java.io.IOException
-import javax.inject.Inject
-
-class ChapterTrackExtractor @Inject constructor(private val context: Context) {
-
-  fun extractFromTrackId(
-    uri: Uri,
-    trackId: Int,
-  ): List<MarkData> {
-    val chapters = mutableListOf<MarkData>()
-    val dataSource = DefaultDataSource.Factory(context).createDataSource()
-
-    try {
-      dataSource.open(DataSpec(uri))
-      processTrack(dataSource, uri, trackId, chapters)
-    } catch (e: IOException) {
-      Logger.e(e, "IO error during chapter track extraction")
-    } finally {
-      try {
-        dataSource.close()
-      } catch (e: IOException) {
-        Logger.w(e, "Error closing data source")
-      }
-    }
-
-    return chapters.sorted()
-  }
-
-  private fun processTrack(
-    dataSource: DataSource,
-    uri: Uri,
-    trackId: Int,
-    outputChapters: MutableList<MarkData>,
-  ) {
-    var extractorInput = DefaultExtractorInput(dataSource, 0, C.LENGTH_UNSET.toLong())
-    val positionHolder = PositionHolder()
-
-    val extractor = Mp4Extractor(DefaultSubtitleParserFactory())
-    val extractorOutput = ChapterTrackOutput(trackId, outputChapters)
-    extractor.init(extractorOutput)
-
-    while (true) {
-      val result = extractor.read(extractorInput, positionHolder)
-      when (result) {
-        Extractor.RESULT_CONTINUE -> {
-          // Continue reading
-        }
-        Extractor.RESULT_SEEK -> {
-          dataSource.close()
-          var length = dataSource.open(
-            DataSpec.Builder()
-              .setUri(uri)
-              .setPosition(positionHolder.position)
-              .build(),
-          )
-
-          if (length != C.LENGTH_UNSET.toLong()) {
-            length += positionHolder.position
-          }
-
-          Logger.d("Seeking to position ${positionHolder.position}")
-          extractorInput = DefaultExtractorInput(dataSource, positionHolder.position, length)
-        }
-        Extractor.RESULT_END_OF_INPUT -> break
-      }
-    }
-  }
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt
deleted file mode 100644
index 10d74401e7..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package voice.app.scanner.mp4
-
-import androidx.media3.common.C
-import androidx.media3.extractor.DiscardingTrackOutput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.SeekMap
-import androidx.media3.extractor.TrackOutput
-import voice.data.MarkData
-
-internal class ChapterTrackOutput(
-  private val targetTrackId: Int,
-  private val outputChapters: MutableList<MarkData>,
-) : ExtractorOutput {
-
-  private var chapterCueOutput: ChapterCueProcessor? = null
-
-  override fun track(
-    id: Int,
-    type: Int,
-  ): TrackOutput {
-    if (type == C.TRACK_TYPE_TEXT && id == targetTrackId) {
-      val processor = ChapterCueProcessor(outputChapters)
-      if (chapterCueOutput == null) {
-        chapterCueOutput = processor
-      }
-      return processor
-    }
-
-    return DiscardingTrackOutput()
-  }
-
-  override fun endTracks() {}
-  override fun seekMap(seekMap: SeekMap) {}
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt
new file mode 100644
index 0000000000..26b1094cfc
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt
@@ -0,0 +1,109 @@
+package voice.app.scanner.mp4
+
+import android.net.Uri
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import voice.data.MarkData
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class ChapterTrackProcessor
+@Inject constructor() {
+
+  operator fun invoke(
+    uri: Uri,
+    dataSource: DataSource,
+    trackId: Int,
+    output: Mp4ChpaterExtractorOutput,
+  ): List<MarkData> {
+    val chunkOffsets = output.chunkOffsets.getOrNull(trackId - 1)
+    if (chunkOffsets == null) {
+      Logger.w("No chunk offsets found for track ID $trackId")
+      return emptyList()
+    }
+    val timeScale = output.timeScales.getOrNull(trackId - 1)
+    if (timeScale == null) {
+      Logger.w("No time scale found for track ID $trackId")
+      return emptyList()
+    }
+    val durations = output.durations.getOrNull(trackId - 1)
+    if (durations == null) {
+      Logger.w("No durations found for track ID $trackId")
+      return emptyList()
+    }
+    val stscEntries = output.stscEntries.getOrNull(trackId - 1)
+    if (stscEntries == null) {
+      Logger.w("No stsc entries found for track ID $trackId")
+      return emptyList()
+    }
+
+    val numberOfChaptersToProcess = chunkOffsets.size
+
+    val names = chunkOffsets.map { offset ->
+      dataSource.close()
+      dataSource.open(
+        DataSpec.Builder()
+          .setUri(uri)
+          .setPosition(offset)
+          .build(),
+      )
+      val buffer = ParsableByteArray()
+      buffer.reset(2)
+      dataSource.read(buffer.data, 0, 2)
+      val textLength = buffer.readShort().toInt()
+      buffer.reset(textLength)
+      dataSource.read(buffer.data, 0, textLength)
+      buffer.readString(textLength)
+    }
+
+    if (names.size != numberOfChaptersToProcess) {
+      Logger.w("Mismatch in names size and chunk offsets size for track ID $trackId")
+      return emptyList()
+    }
+
+    var position = 0L
+    var sampleIndex = 0
+
+    return (0 until numberOfChaptersToProcess)
+      .map { chunkIndex ->
+        val chapterName = names[chunkIndex]
+
+        val samplesInThisChunk = getSamplesPerChunk(chunkIndex, stscEntries)
+
+        var chunkDuration = 0L
+
+        repeat(samplesInThisChunk) {
+          if (sampleIndex < durations.size) {
+            chunkDuration += durations[sampleIndex]
+            sampleIndex++
+          } else {
+            Logger.w("Not enough sample durations for chunk ${chunkIndex + 1}")
+          }
+        }
+
+        MarkData(
+          startMs = position * 1000 / timeScale,
+          name = chapterName,
+        ).also {
+          position += chunkDuration
+        }
+      }
+      .sorted()
+  }
+
+  private fun getSamplesPerChunk(
+    chunkIndex: Int,
+    stscEntries: List<StscEntry>,
+  ): Int {
+    stscEntries.forEachIndexed { index, entry ->
+      val nextEntry = stscEntries.getOrNull(index + 1)
+      if (chunkIndex + 1 >= entry.firstChunk) {
+        if (nextEntry == null || chunkIndex + 1 < nextEntry.firstChunk) {
+          return entry.samplesPerChunk
+        }
+      }
+    }
+    return 1
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt
new file mode 100644
index 0000000000..567a419f59
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt
@@ -0,0 +1,129 @@
+package voice.app.scanner.mp4
+
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.container.Mp4Box
+import androidx.media3.extractor.ExtractorInput
+import voice.app.scanner.mp4.visitor.ChapVisitor
+import voice.app.scanner.mp4.visitor.ChplVisitor
+import voice.app.scanner.mp4.visitor.MdhdVisitor
+import voice.app.scanner.mp4.visitor.StcoVisitor
+import voice.app.scanner.mp4.visitor.StscVisitor
+import voice.app.scanner.mp4.visitor.SttsVisitor
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class Mp4BoxParser
+@Inject constructor(
+  stscVisitor: StscVisitor,
+  mdhdVisitor: MdhdVisitor,
+  sttsVisitor: SttsVisitor,
+  stcoVisitor: StcoVisitor,
+  chplVisitor: ChplVisitor,
+  chapVisitor: ChapVisitor,
+) {
+
+  private val visitors = listOf(
+    stscVisitor,
+    mdhdVisitor,
+    sttsVisitor,
+    stcoVisitor,
+    chplVisitor,
+    chapVisitor,
+  )
+  private val visitorByPath = visitors.associateBy { it.path }
+
+  operator fun invoke(input: ExtractorInput): Mp4ChpaterExtractorOutput {
+    val scratch = ParsableByteArray(Mp4Box.LONG_HEADER_SIZE)
+    val parseOutput = Mp4ChpaterExtractorOutput()
+    parseBoxes(
+      input = input,
+      path = emptyList(),
+      parentEnd = Long.MAX_VALUE,
+      scratch = scratch,
+      parseOutput = parseOutput,
+    )
+    return parseOutput
+  }
+
+  private fun parseBoxes(
+    input: ExtractorInput,
+    path: List<String>,
+    parentEnd: Long,
+    scratch: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    while (input.position < parentEnd) {
+      scratch.reset(Mp4Box.HEADER_SIZE)
+      if (!input.readFully(scratch.data, 0, Mp4Box.HEADER_SIZE, true)) {
+        return
+      }
+
+      var atomSize = scratch.readUnsignedInt()
+      val atomType = scratch.readString(4)
+      var headerSize = Mp4Box.HEADER_SIZE
+
+      if (atomSize == 1L) {
+        input.readFully(
+          scratch.data,
+          Mp4Box.HEADER_SIZE,
+          Mp4Box.LONG_HEADER_SIZE - Mp4Box.HEADER_SIZE,
+        )
+        scratch.setPosition(Mp4Box.HEADER_SIZE)
+        atomSize = scratch.readUnsignedLongToLong()
+        headerSize = Mp4Box.LONG_HEADER_SIZE
+      }
+
+      val payloadSize = (atomSize - headerSize).toInt()
+      val payloadEnd = input.position + payloadSize
+      val currentPath = path + atomType
+      Logger.d("Current path: $currentPath, atomType: $atomType")
+
+      val visitor = visitorByPath[currentPath]
+
+      when {
+        visitor != null -> {
+          Logger.v("Found ${visitor.path.last()}!")
+          scratch.reset(payloadSize)
+          if (!input.readFully(scratch.data, 0, payloadSize, true)) {
+            return
+          }
+          visitor.visit(scratch, parseOutput)
+
+          if (parseOutput.chplChapters.isNotEmpty()) {
+            return
+          }
+        }
+
+        visitors.any { it.path.startsWith(currentPath) } -> {
+          parseBoxes(
+            input = input,
+            path = currentPath,
+            parentEnd = payloadEnd,
+            scratch = scratch,
+            parseOutput = parseOutput,
+          )
+
+          if (parseOutput.chplChapters.isNotEmpty()) {
+            return
+          }
+        }
+
+        else -> {
+          if (!input.skipFully(payloadSize, true)) {
+            return
+          }
+        }
+      }
+
+      if (input.position < payloadEnd) {
+        if (!input.skipFully((payloadEnd - input.position).toInt(), true)) {
+          return
+        }
+      }
+    }
+  }
+
+  private fun List<String>.startsWith(other: List<String>): Boolean {
+    return take(other.size) == other
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
index af29b04932..d3dc193b63 100644
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
@@ -3,12 +3,9 @@ package voice.app.scanner.mp4
 import android.content.Context
 import android.net.Uri
 import androidx.media3.common.C
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.container.Mp4Box
 import androidx.media3.datasource.DataSpec
 import androidx.media3.datasource.DefaultDataSource
 import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.ExtractorInput
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import voice.data.MarkData
@@ -16,21 +13,11 @@ import voice.logging.core.Logger
 import java.io.IOException
 import javax.inject.Inject

-/**
- * Extracts chapter information from MP4 files using two methods:
- *
- * 1. Primary method: Search for 'chpl' atom in MP4 structure, which directly contains chapter data
- * 2. Fallback method: If 'chap' atom is found, extract chapter info from the referenced text track
- *
- * The extraction process:
- * - Opens MP4 file and traverses its box structure (moov → udta/trak → chpl/tref/chap)
- * - If 'chpl' atom is found, parses timestamps and titles directly
- * - If 'chap' atom is found, uses ChapterTrackExtractor to process the referenced text track
- * - Returns chapters as MarkData objects with timestamps and names
- */
-class Mp4ChapterExtractor @Inject constructor(
+class Mp4ChapterExtractor
+@Inject constructor(
   private val context: Context,
-  private val chapterTrackExtractor: ChapterTrackExtractor,
+  private val boxParser: Mp4BoxParser,
+  private val chapterTrackProcessor: ChapterTrackProcessor,
 ) {

   suspend fun extractChapters(uri: Uri): List<MarkData> = withContext(Dispatchers.IO) {
@@ -38,167 +25,34 @@ class Mp4ChapterExtractor @Inject constructor(

     try {
       dataSource.open(DataSpec(uri))
-
       val input = DefaultExtractorInput(dataSource, 0, C.LENGTH_UNSET.toLong())
-      val result = parseTopLevelBoxes(input)
-
-      when (result) {
-        is ChapterParseResult.ChplChapters -> result.chapters
-        is ChapterParseResult.ChapterTrackId -> {
-          chapterTrackExtractor.extractFromTrackId(uri, result.trackId)
+      val topLevelResult = boxParser(input)
+      val trackId = topLevelResult.chapterTrackId
+      when {
+        topLevelResult.chplChapters.isNotEmpty() -> {
+          topLevelResult.chplChapters
         }
-
-        null -> emptyList()
+        trackId != null -> {
+          chapterTrackProcessor(uri, dataSource, trackId, topLevelResult)
+        }
+        else -> emptyList()
       }
     } catch (e: IOException) {
       Logger.w(e, "Failed to open MP4 file for chapter extraction")
       emptyList()
-    } finally {
-      try {
-        dataSource.close()
-      } catch (e: IOException) {
-        Logger.w(e, "Error closing data source")
-      }
-    }
-  }
-
-  private fun parseTopLevelBoxes(input: ExtractorInput): ChapterParseResult? {
-    val scratch = ParsableByteArray(Mp4Box.LONG_HEADER_SIZE)
-    return try {
-      parseBoxes(input = input, depth = 0, parentEnd = Long.MAX_VALUE, scratch = scratch)
-    } catch (e: IOException) {
-      Logger.w(e, "Failed to parse MP4 boxes")
-      null
     } catch (e: IllegalStateException) {
       Logger.w(e, "Invalid MP4 structure")
-      null
+      emptyList()
     } catch (e: ArrayIndexOutOfBoundsException) {
       Logger.w(e, "Undeclared")
       // https://github.com/androidx/media/issues/2467
-      null
-    }
-  }
-
-  private fun parseBoxes(
-    input: ExtractorInput,
-    depth: Int,
-    parentEnd: Long,
-    scratch: ParsableByteArray,
-  ): ChapterParseResult? {
-    var chapterTrackId: ChapterParseResult.ChapterTrackId? = null
-
-    while (input.position < parentEnd) {
-      scratch.reset(Mp4Box.HEADER_SIZE)
-      if (!input.readFully(scratch.data, 0, Mp4Box.HEADER_SIZE, true)) {
-        return chapterTrackId
-      }
-
-      scratch.setPosition(0)
-      var atomSize = scratch.readUnsignedInt()
-      val atomType = scratch.readString(4)
-      var headerSize = Mp4Box.HEADER_SIZE
-
-      if (atomSize == 1L) {
-        input.readFully(
-          scratch.data,
-          Mp4Box.HEADER_SIZE,
-          Mp4Box.LONG_HEADER_SIZE - Mp4Box.HEADER_SIZE,
-        )
-        scratch.setPosition(Mp4Box.HEADER_SIZE)
-        atomSize = scratch.readUnsignedLongToLong()
-        headerSize = Mp4Box.LONG_HEADER_SIZE
-      }
-
-      val payloadSize = (atomSize - headerSize).toInt()
-      val payloadEnd = input.position + payloadSize
-
-      when {
-        (depth == 0 && atomType == "moov") ||
-          (depth == 1 && atomType in listOf("udta", "trak")) ||
-          (depth == 2 && atomType == "tref") -> {
-          val result = parseBoxes(
-            input = input,
-            depth = depth + 1,
-            parentEnd = payloadEnd,
-            scratch = scratch,
-          )
-
-          if (result is ChapterParseResult.ChplChapters) {
-            return result
-          }
-
-          if (result is ChapterParseResult.ChapterTrackId) {
-            chapterTrackId = result
-          }
-        }
-
-        depth == 2 && atomType == "chpl" -> {
-          val buffer = ParsableByteArray(payloadSize)
-          if (!input.readFully(buffer.data, 0, payloadSize, true)) {
-            return chapterTrackId
-          }
-
-          val chapters = parseChplAtom(buffer)
-          if (chapters.isNotEmpty()) {
-            return ChapterParseResult.ChplChapters(chapters)
-          }
-        }
-
-        atomType == "chap" -> {
-          val buffer = ParsableByteArray(payloadSize)
-          if (!input.readFully(buffer.data, 0, payloadSize, true)) {
-            return chapterTrackId
-          }
-
-          val trackId = buffer.readUnsignedIntToInt()
-          chapterTrackId = ChapterParseResult.ChapterTrackId(trackId)
-        }
-
-        else -> {
-          if (!input.skipFully(payloadSize, true)) {
-            return chapterTrackId
-          }
-        }
-      }
-
-      if (input.position < payloadEnd) {
-        if (!input.skipFully((payloadEnd - input.position).toInt(), true)) {
-          return chapterTrackId
-        }
-      }
-    }
-
-    return chapterTrackId
-  }
-
-  private fun parseChplAtom(data: ParsableByteArray): List<MarkData> {
-    data.setPosition(0)
-    val version = data.readUnsignedByte()
-    data.skipBytes(3) // flags
-    data.skipBytes(1) // reserved
-    val chapterCount = data.readUnsignedIntToInt()
-
-    val chapters = mutableListOf<MarkData>()
-    repeat(chapterCount) {
-      val timestamp = if (version == 0) {
-        data.readUnsignedInt()
-      } else {
-        data.readUnsignedLongToLong()
+      emptyList()
+    } finally {
+      try {
+        dataSource.close()
+      } catch (e: IOException) {
+        Logger.w(e, "Error closing data source")
       }
-
-      val titleLength = data.readUnsignedByte()
-      val title = data.readString(titleLength)
-
-      // Convert from 100ns units to milliseconds
-      val startTimeMs = timestamp / 10_000
-      chapters.add(MarkData(startMs = startTimeMs, name = title))
     }
-
-    return chapters
-  }
-
-  private sealed class ChapterParseResult {
-    data class ChplChapters(val chapters: List<MarkData>) : ChapterParseResult()
-    data class ChapterTrackId(val trackId: Int) : ChapterParseResult()
   }
 }
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt
new file mode 100644
index 0000000000..1f02702df2
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt
@@ -0,0 +1,17 @@
+package voice.app.scanner.mp4
+
+import voice.data.MarkData
+
+data class Mp4ChpaterExtractorOutput(
+  val chunkOffsets: MutableList<List<Long>> = mutableListOf(),
+  val durations: MutableList<List<Long>> = mutableListOf(),
+  val stscEntries: MutableList<List<StscEntry>> = mutableListOf(),
+  val timeScales: MutableList<Long> = mutableListOf(),
+  var chplChapters: List<MarkData> = emptyList(),
+  var chapterTrackId: Int? = null,
+)
+
+data class StscEntry(
+  val firstChunk: Long,
+  val samplesPerChunk: Int,
+)
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt
new file mode 100644
index 0000000000..6fa6a857af
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt
@@ -0,0 +1,13 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+
+interface AtomVisitor {
+  val path: List<String>
+
+  fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  )
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt
new file mode 100644
index 0000000000..e4f908d172
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt
@@ -0,0 +1,18 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import javax.inject.Inject
+
+class ChapVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "tref", "chap")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val trackId = buffer.readUnsignedIntToInt()
+    parseOutput.chapterTrackId = trackId
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt
new file mode 100644
index 0000000000..857a4756c3
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt
@@ -0,0 +1,49 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.data.MarkData
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class ChplVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "udta", "chpl")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    buffer.setPosition(0)
+    val version = buffer.readUnsignedByte()
+    buffer.skipBytes(3) // flags
+
+    if (version != 0 && version != 1) {
+      Logger.w("Unexpected version $version in chpl atom, expected 0 or 1")
+      return
+    }
+
+    if (version == 1) {
+      buffer.skipBytes(4)
+    }
+
+    val chapterCount = buffer.readUnsignedByte()
+
+    val chapters = (0 until chapterCount).map {
+      val timestamp = if (version == 0) {
+        buffer.readUnsignedInt()
+      } else {
+        buffer.readUnsignedLongToLong()
+      }
+
+      val titleLength = buffer.readUnsignedByte()
+      val title = buffer.readString(titleLength)
+
+      // Convert from 100ns units to milliseconds (10,000 units per ms)
+      val startTimeMs = timestamp / 10_000
+      MarkData(startMs = startTimeMs, name = title)
+    }
+
+    parseOutput.chplChapters = chapters
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt
new file mode 100644
index 0000000000..89800751ca
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt
@@ -0,0 +1,30 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/media_header_atom
+class MdhdVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "mdhd")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0 && version != 1) {
+      Logger.w("Unexpected version $version in mdhd atom, expected 0 or 1")
+    } else {
+      val flagsSize = 3
+      val creationTimeSize = if (version == 0) 4 else 8
+      val modificationTimeSize = if (version == 0) 4 else 8
+      buffer.skipBytes(flagsSize + creationTimeSize + modificationTimeSize)
+      val timescale = buffer.readUnsignedInt()
+      Logger.v("Timescale: $timescale")
+      parseOutput.timeScales += timescale
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt
new file mode 100644
index 0000000000..f5ad6a9d7c
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt
@@ -0,0 +1,28 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom
+class StcoVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stco")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stco atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntries = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stco: $numberOfEntries")
+      val chunkOffsets = (0 until numberOfEntries).map { buffer.readUnsignedInt() }
+      parseOutput.chunkOffsets.add(chunkOffsets)
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt
new file mode 100644
index 0000000000..7cf10b9a32
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt
@@ -0,0 +1,37 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.app.scanner.mp4.StscEntry
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/sample-to-chunk_atom/
+class StscVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stsc")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stsc atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntries = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stsc: $numberOfEntries")
+      val stscEntriesForTrack = (0 until numberOfEntries).map {
+        val firstChunk = buffer.readUnsignedInt()
+        val samplesPerChunk = buffer.readUnsignedIntToInt()
+        buffer.skipBytes(4) // skip sample description index
+        StscEntry(
+          firstChunk = firstChunk,
+          samplesPerChunk = samplesPerChunk,
+        )
+      }
+      parseOutput.stscEntries.add(stscEntriesForTrack)
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt
new file mode 100644
index 0000000000..d69d087504
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt
@@ -0,0 +1,35 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/time-to-sample_atom
+class SttsVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stts")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stts atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntriesInSttsTable = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stts: $numberOfEntriesInSttsTable")
+      val individualSampleDurations = mutableListOf<Long>()
+      repeat(numberOfEntriesInSttsTable) {
+        val count = buffer.readUnsignedInt().toInt()
+        val delta = buffer.readUnsignedInt()
+        repeat(count) {
+          individualSampleDurations.add(delta)
+        }
+      }
+      parseOutput.durations.add(individualSampleDurations)
+    }
+  }
+}

diff --git a/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt b/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
index 10a6c3a3fc..1ef17b4b49 100644
--- a/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
+++ b/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
@@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import io.kotest.assertions.assertSoftly
 import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.collections.shouldHaveElementAt
 import io.kotest.matchers.longs.shouldBeWithinPercentageOf
 import io.kotest.matchers.nulls.shouldNotBeNull
 import io.kotest.matchers.shouldBe
@@ -14,8 +15,15 @@ import org.junit.Test
 import org.junit.rules.TemporaryFolder
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
-import voice.app.scanner.mp4.ChapterTrackExtractor
+import voice.app.scanner.mp4.ChapterTrackProcessor
+import voice.app.scanner.mp4.Mp4BoxParser
 import voice.app.scanner.mp4.Mp4ChapterExtractor
+import voice.app.scanner.mp4.visitor.ChapVisitor
+import voice.app.scanner.mp4.visitor.ChplVisitor
+import voice.app.scanner.mp4.visitor.MdhdVisitor
+import voice.app.scanner.mp4.visitor.StcoVisitor
+import voice.app.scanner.mp4.visitor.StscVisitor
+import voice.app.scanner.mp4.visitor.SttsVisitor
 import voice.data.MarkData
 import voice.documentfile.FileBasedDocumentFile
 import voice.logging.core.LogWriter
@@ -34,7 +42,15 @@ internal class MediaAnalyzerTest {
     context = ApplicationProvider.getApplicationContext(),
     mp4ChapterExtractor = Mp4ChapterExtractor(
       context = ApplicationProvider.getApplicationContext(),
-      chapterTrackExtractor = ChapterTrackExtractor(context = ApplicationProvider.getApplicationContext()),
+      boxParser = Mp4BoxParser(
+        stscVisitor = StscVisitor(),
+        mdhdVisitor = MdhdVisitor(),
+        sttsVisitor = SttsVisitor(),
+        stcoVisitor = StcoVisitor(),
+        chplVisitor = ChplVisitor(),
+        chapVisitor = ChapVisitor(),
+      ),
+      chapterTrackProcessor = ChapterTrackProcessor(),
     ),
   )
   private val auphonicChapters = listOf(
@@ -85,6 +101,15 @@ internal class MediaAnalyzerTest {
     }
   }

+  @Test
+  fun chapterTrackId() {
+    val chapters = parse("chapter_track_id.m4b")
+      .shouldNotBeNull()
+      .chapters
+    chapters.shouldHaveElementAt(0, MarkData(0, "Opening Credits"))
+    chapters.shouldHaveElementAt(107, MarkData(103121056, "Closing Credits"))
+  }
+
   @Test
   fun opus() {
     val metadata = parse("auphonic_chapters_demo.opus")
@@ -112,7 +137,7 @@ internal class MediaAnalyzerTest {
       metadata.album shouldBe "Auphonic Examples"
       metadata.chapters shouldContainExactly auphonicChapters.filter {
         // for some reason only this one is missing in the test files
-        it.name != "Sound analysis"
+        it.name != "Creating a new production"
       }
     }
   }
@@ -143,7 +168,15 @@ internal class MediaAnalyzerTest {
             message: String,
             throwable: Throwable?,
           ) {
-            println("$severity: $message, $throwable")
+            println(
+              buildString {
+                append("${severity.name}: ")
+                append(message)
+                if (throwable != null) {
+                  append(", $throwable")
+                }
+              },
+            )
           }
         },
       )
diff --git a/scanner/src/test/resources/chapter_track_id.m4b b/scanner/src/test/resources/chapter_track_id.m4b
new file mode 100644
index 0000000000..a04cb04fc5
Binary files /dev/null and b/scanner/src/test/resources/chapter_track_id.m4b differ