Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
de82485
feat(thunderwren): add a demo mailbox for trying the app without a phone
claude Sep 24, 2026
ac066f0
chore(deps): update app-thunderbird dependency guard for the Wear com…
claude Sep 24, 2026
56cac76
feat(notification): add Star to Wear notification actions and follow …
claude Sep 24, 2026
04eecd4
ci: build the ThunderWren Wear OS app
claude Sep 24, 2026
444fa42
fix(thunderwren): make the demo mailbox translatable and improve scre…
claude Sep 24, 2026
1f3f224
docs: explain the demo mailbox and the notification improvements
claude Sep 24, 2026
9e49a2f
fix(thunderwren): fix layout issues found in rendered screenshots
claude Sep 24, 2026
c3984db
docs: add watch screenshots of the demo mailbox to the README
claude Sep 24, 2026
3d903e3
docs: point the README status at 0.1.0-beta6
claude Oct 3, 2026
8451cb9
feat(wear): ThunderWren 0.1.0-beta7 — folders, and no standalone mode
claude Oct 3, 2026
4748b82
Merge the squash-merged #15 branch head (no changes)
claude Oct 3, 2026
9c0f4f6
docs: say RFC 0010 isn't submitted upstream yet
claude Oct 3, 2026
caa6020
Merge the squash-merged #16 branch head (no changes)
claude Oct 3, 2026
6f762cd
docs: mark the Wear OS request as presented upstream
claude Oct 3, 2026
20d297e
Merge the squash-merged #17 branch head (no changes)
claude Oct 3, 2026
0ea4ee9
fix(wear): ThunderWren 0.1.0-beta8 — follow-ups from the pre-submissi…
claude Oct 3, 2026
afec2f4
Merge main (#18) into beta8
claude Oct 3, 2026
e4b52d8
Merge the squash-merged #18 branch head (no changes)
claude Oct 3, 2026
c92d50c
Merge the squash-merged #18 branch head (no changes)
claude Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ ThunderWren brings your Thunderbird mail to smartwatches running Wear OS 3+. Thu
It lives in a fork of the Thunderbird for Android repository. The design is written up as an RFC in Thunderbird's format, [RFC 0010](docs/engineering/rfcs/0010-wear-os-companion.md), to propose upstream.

> [!IMPORTANT]
> **Project status: beta ([0.1.0-beta7](https://github.com/JUhalt/thunderbird-android/releases/tag/v0.1.0-beta7)).** The phone and watch sides are implemented, covered by unit and UI tests, and tested on a Pixel 10 Pro Fold with a Pixel Watch 5, as well as on emulators. The watch needs the Thunderbird phone app **built from this fork**; the Thunderbird app from the Play Store doesn't include the companion.
> **Project status: beta ([0.1.0-beta8](https://github.com/JUhalt/thunderbird-android/releases/tag/v0.1.0-beta8)).** The phone and watch sides are implemented, covered by unit and UI tests, and tested on a Pixel 10 Pro Fold with a Pixel Watch 5, as well as on emulators. The watch needs the Thunderbird phone app **built from this fork**; the Thunderbird app from the Play Store doesn't include the companion.
>
> **Built with AI.** Most of this code was written with an AI coding assistant (Claude Code), directed and tested by the fork's maintainer. It follows the repository's [`AGENTS.md`](AGENTS.md) rules for AI-assisted contributions.
>
Expand Down
4 changes: 2 additions & 2 deletions app-thunderwren/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ android {
// app-thunderbird (including the build type suffixes below). See RFC 0010.
applicationId = "net.thunderbird.android"

versionCode = 7
versionName = "0.1.0-beta7"
versionCode = 8
versionName = "0.1.0-beta8"

minSdk = 30
targetSdk = 35
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,11 @@ private fun MailboxHeader(
val unreadCount = state.mailbox.unreadCount
val unreadCountText = pluralStringResource(R.plurals.unread_count, unreadCount, unreadCount)
val unreadText = if (state.isDemo) stringResource(R.string.demo_label, unreadCountText) else unreadCountText
val switchDescription = stringResource(R.string.mailbox_switch_description, mailboxName)
val switchDescription = if (state.folderType != null) {
stringResource(R.string.folder_back_description, mailboxName)
} else {
stringResource(R.string.mailbox_switch_description, mailboxName)
}

FilledTonalButton(
onClick = onClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ private val MessageSwipeAction.label: Int
private fun SettingsScreenPreview() {
ThunderWrenTheme {
SettingsScreen(
state = State(appVersion = "0.1.0-beta7"),
state = State(appVersion = "0.1.0-beta8"),
onEvent = {},
)
}
Expand Down
2 changes: 2 additions & 0 deletions app-thunderwren/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<!-- Screen reader description of the button that lists an account's folders. %1$s is the account name. -->
<string name="mailbox_picker_folders_of">Folders of %1$s</string>
<string name="mailbox_switch_description">Switch mailbox. Showing %1$s.</string>
<!-- Screen reader description of the folder name at the top of a folder. %1$s is the folder name. -->
<string name="folder_back_description">Back to the folder list. Showing %1$s.</string>

<plurals name="unread_count">
<item quantity="one">%1$d unread</item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ class ThunderWrenAppTest {
composeRule.onNodeWithText("Corner Shop").assertIsDisplayed()
// The Sent folder's messages and others the account sent name who they went to.
composeRule.onNodeWithText("To: Bo").assertIsDisplayed()
// The folder name at the top goes back to the folder list, and says so to screen readers.
composeRule.onNodeWithContentDescription(
"Back to the folder list. Showing Receipts. 0 unread",
).assertIsDisplayed()

composeRule.onNodeWithText("Corner Shop").performClick()
composeRule.onNodeWithText("Your receipt").assertIsDisplayed()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ val featureWearCompanionModule = module {
messagingController = get(),
accountManager = get(),
messageListRepository = get(),
folderSource = get(),
)
}
factory<WearReplySender> { QuickReplyWearReplySender(quickReplySender = get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import net.thunderbird.feature.wear.companion.WearResponse
import net.thunderbird.feature.wear.companion.internal.WearMailboxSearches.onlyUnread

/** Applies a watch action to a whole mailbox. Must be called off the main thread. */
internal fun interface WearMailboxActions {
fun markAllRead(mailboxId: String): WearResponse
internal interface WearMailboxActions {
suspend fun markAllRead(mailboxId: String): WearResponse
}

internal class MessagingControllerWearMailboxActions(
private val messagingController: MessagingController,
private val accountManager: LegacyAccountDtoManager,
private val messageListRepository: MessageListRepository,
private val folderSource: WearFolderSource,
) : WearMailboxActions {

override fun markAllRead(mailboxId: String): WearResponse {
override suspend fun markAllRead(mailboxId: String): WearResponse {
val mailbox = findMailbox(mailboxId) ?: return WearResponse.Error(WearErrorReason.MAILBOX_NOT_FOUND)

// Without a known inbox there is nothing to mark.
Expand All @@ -40,16 +41,19 @@ internal class MessagingControllerWearMailboxActions(
return WearResponse.Ok
}

private fun findMailbox(mailboxId: String): Mailbox? {
private suspend fun findMailbox(mailboxId: String): Mailbox? {
val unifiedView = WearMailboxSearches.unifiedView(mailboxId)
val folder = WearCompanion.parseFolderMailboxId(mailboxId)
return when {
unifiedView != null -> Mailbox(accountManager.getAccounts(), unifiedView)

// Only the folders the watch can see, so hidden folders and Drafts stay as they are.
folder != null -> {
val (accountUuid, folderId) = folder
accountManager.getAccount(accountUuid)?.let { account ->
Mailbox(listOf(account), WearMailboxSearches.folder(account.uuid, folderId))
val account = accountManager.getAccount(accountUuid)
val shownFolders = (folderSource.loadFolders(accountUuid) as? WearResponse.Folders)?.folders
account?.takeIf { shownFolders?.any { it.id == folderId } == true }?.let {
Mailbox(listOf(it), WearMailboxSearches.folder(it.uuid, folderId))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ internal class WearRequestHandler(
}

/** Runs [block] off the main thread and, if it succeeded, republishes so the watch sees the result. */
private suspend fun republishIfOk(block: () -> WearResponse): WearResponse {
private suspend fun republishIfOk(block: suspend () -> WearResponse): WearResponse {
val response = withContext(ioDispatcher) { block() }

if (response == WearResponse.Ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ internal class FakeWearMessageActions(var response: WearResponse = WearResponse.
internal class FakeWearMailboxActions(var response: WearResponse = WearResponse.Ok) : WearMailboxActions {
val markedAllRead = mutableListOf<String>()

override fun markAllRead(mailboxId: String): WearResponse {
override suspend fun markAllRead(mailboxId: String): WearResponse {
markedAllRead += mailboxId
return response
}
Expand All @@ -140,9 +140,12 @@ internal class FakeWearMessageBodyLoader(
}
}

internal class FakeWearFolderSource : WearFolderSource {
private val folder = WearFolder(id = 12, name = "Receipts", type = WearFolderType.REGULAR, unreadCount = 0)
val foldersResponse = WearResponse.Folders(listOf(folder))
internal class FakeWearFolderSource(folderIds: List<Long> = listOf(12)) : WearFolderSource {
private val folder =
WearFolder(id = folderIds.first(), name = "Receipts", type = WearFolderType.REGULAR, unreadCount = 0)
val foldersResponse = WearResponse.Folders(
folderIds.map { WearFolder(id = it, name = "Folder $it", type = WearFolderType.REGULAR, unreadCount = 0) },
)
val folderResponse = WearResponse.Folder(
folder = folder,
snapshot = WearInboxSnapshot(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import com.fsck.k9.controller.MessagingController
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.android.account.LegacyAccountDto
import net.thunderbird.core.android.account.LegacyAccountDtoManager
import net.thunderbird.core.common.mail.Flag
Expand All @@ -28,7 +29,7 @@ class WearMailboxActionsTest {
private val messagingController = mock<MessagingController>()

@Test
fun `mark all read in an account marks its unread inbox messages`() {
fun `mark all read in an account marks its unread inbox messages`() = runTest {
val repository = FakeMessageListRepository(
messages = mapOf(
WORK_UUID to listOf(FakeMessageDetailsAccessor(id = 10), FakeMessageDetailsAccessor(id = 11)),
Expand All @@ -45,7 +46,7 @@ class WearMailboxActionsTest {
}

@Test
fun `mark all read in a view of the unified inbox covers every account`() {
fun `mark all read in a view of the unified inbox covers every account`() = runTest {
val repository = FakeMessageListRepository(
messages = mapOf(
WORK_UUID to listOf(FakeMessageDetailsAccessor(id = 10)),
Expand All @@ -61,7 +62,7 @@ class WearMailboxActionsTest {
}

@Test
fun `mark all read in a folder marks its unread messages`() {
fun `mark all read in a folder marks its unread messages`() = runTest {
val repository = FakeMessageListRepository(
messages = mapOf(WORK_UUID to listOf(FakeMessageDetailsAccessor(id = 30))),
)
Expand All @@ -74,15 +75,27 @@ class WearMailboxActionsTest {
}

@Test
fun `nothing is changed when there are no unread messages`() {
fun `mark all read in a folder the watch can't see changes nothing`() = runTest {
val repository = FakeMessageListRepository(
messages = mapOf(WORK_UUID to listOf(FakeMessageDetailsAccessor(id = 30))),
)

val response = createTestSubject(repository).markAllRead(WearCompanion.folderMailboxId(WORK_UUID, 99))

assertThat(response).isEqualTo(WearResponse.Error(WearErrorReason.MAILBOX_NOT_FOUND))
verify(messagingController, never()).setFlag(any(), any<List<Long>>(), any(), any())
}

@Test
fun `nothing is changed when there are no unread messages`() = runTest {
val response = createTestSubject(FakeMessageListRepository()).markAllRead(WearCompanion.UNIFIED_MAILBOX_ID)

assertThat(response).isEqualTo(WearResponse.Ok)
verify(messagingController, never()).setFlag(any(), any<List<Long>>(), any(), any())
}

@Test
fun `removed account is reported`() {
fun `removed account is reported`() = runTest {
val response = createTestSubject(
FakeMessageListRepository(),
).markAllRead("00000000-0000-4000-8000-000000000000")
Expand All @@ -94,6 +107,7 @@ class WearMailboxActionsTest {
messagingController = messagingController,
accountManager = accountManager,
messageListRepository = repository,
folderSource = FakeWearFolderSource(folderIds = listOf(5)),
)

private companion object {
Expand Down
Loading