Skip to content

Commit 199cc94

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/send-retry-duplicates
# Conflicts: # app/src/main/kotlin/org/astermail/android/mail/MailRepository.kt # app/src/main/res/values-ar/strings.xml # app/src/main/res/values-de/strings.xml # app/src/main/res/values-es/strings.xml # app/src/main/res/values-fr/strings.xml # app/src/main/res/values-it/strings.xml # app/src/main/res/values-ja/strings.xml # app/src/main/res/values-ko/strings.xml # app/src/main/res/values-nl/strings.xml # app/src/main/res/values-pl/strings.xml # app/src/main/res/values-pt/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-tr/strings.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values/strings.xml
2 parents a407d45 + d638656 commit 199cc94

84 files changed

Lines changed: 6239 additions & 881 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ android {
2929
applicationId = "org.astermail.android"
3030
minSdk = 26
3131
targetSdk = 35
32-
versionCode = 111
33-
versionName = "0.6.102"
32+
versionCode = 117
33+
versionName = "0.6.108"
3434

3535
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3636
vectorDrawables { useSupportLibrary = true }

app/src/androidTest/kotlin/org/astermail/android/notifications/NotificationClearOnReadTest.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,84 @@ class NotificationClearOnReadTest {
128128
assertFalse("summary auto-cleared when empty", MailPollingWorker.SUMMARY_NOTIFICATION_ID in after)
129129
}
130130

131+
@Test
132+
fun notified_item_ledger_marks_and_persists() {
133+
assertFalse(MailPollingWorker.was_item_notified(context, item_a))
134+
MailPollingWorker.mark_item_notified(context, item_a)
135+
assertTrue(MailPollingWorker.was_item_notified(context, item_a))
136+
assertFalse(MailPollingWorker.was_item_notified(context, item_b))
137+
138+
MailPollingWorker.cancel_message_notification(context, item_a)
139+
assertTrue(
140+
"ledger must survive read-cancel so the item is never re-notified",
141+
MailPollingWorker.was_item_notified(context, item_a),
142+
)
143+
}
144+
145+
@Test
146+
fun notified_item_ledger_caps_and_keeps_newest() {
147+
val first = "cap-first-" + System.nanoTime()
148+
MailPollingWorker.mark_item_notified(context, first)
149+
repeat(100) { MailPollingWorker.mark_item_notified(context, "cap-fill-$it-" + System.nanoTime()) }
150+
assertFalse("oldest entry evicted past cap", MailPollingWorker.was_item_notified(context, first))
151+
val last = "cap-last-" + System.nanoTime()
152+
MailPollingWorker.mark_item_notified(context, last)
153+
assertTrue(MailPollingWorker.was_item_notified(context, last))
154+
}
155+
156+
private fun inbox_item(id: String, is_read: Boolean) = org.astermail.android.mail.InboxItem(
157+
id = id,
158+
thread_token = null,
159+
thread_message_count = 1,
160+
sender_name = "Alice",
161+
sender_email = "alice@example.com",
162+
subject = "Subject $id",
163+
preview = "Preview",
164+
timestamp = "now",
165+
is_read = is_read,
166+
is_starred = false,
167+
is_encrypted = false,
168+
has_attachments = false,
169+
is_trashed = false,
170+
is_archived = false,
171+
is_spam = false,
172+
labels = emptyList(),
173+
raw_item = org.astermail.android.api.mail.MailItem(id = id),
174+
)
175+
176+
@Test
177+
fun read_email_is_never_renotified_by_later_wake_polls() {
178+
val id_a = MailPollingWorker.message_notification_id(item_a.hashCode())
179+
180+
val first_pick = MailPollingWorker.pick_notifiable_candidate(
181+
context, listOf(inbox_item(item_a, is_read = false)),
182+
)
183+
assertTrue("fresh unread mail must be picked", first_pick?.id == item_a)
184+
MailPollingWorker.mark_item_notified(context, item_a)
185+
post_and_await("Alice", "Subject", "Preview", id_a)
186+
187+
MailPollingWorker.cancel_message_notification(context, item_a)
188+
await_id(id_a, false)
189+
assertFalse("notification cleared on read", id_a in active_ids())
190+
191+
repeat(3) {
192+
val repick_read = MailPollingWorker.pick_notifiable_candidate(
193+
context, listOf(inbox_item(item_a, is_read = true)),
194+
)
195+
assertTrue("read newest item must never be re-picked", repick_read == null)
196+
val repick_unread_again = MailPollingWorker.pick_notifiable_candidate(
197+
context, listOf(inbox_item(item_a, is_read = false)),
198+
)
199+
assertTrue("already-notified item must never be re-picked", repick_unread_again == null)
200+
}
201+
202+
val next_pick = MailPollingWorker.pick_notifiable_candidate(
203+
context,
204+
listOf(inbox_item(item_b, is_read = false), inbox_item(item_a, is_read = true)),
205+
)
206+
assertTrue("genuinely new mail must still be picked", next_pick?.id == item_b)
207+
}
208+
131209
@Test
132210
fun bulk_read_clears_all_targeted_notifications() {
133211
val id_a = MailPollingWorker.message_notification_id(item_a.hashCode())
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
//
2+
// Aster Communications Inc.
3+
//
4+
// Copyright (c) 2026 Aster Communications Inc.
5+
//
6+
// This file is part of this project.
7+
//
8+
// This program is free software: you can redistribute it and/or modify
9+
// it under the terms of the GNU Affero General Public License as published by
10+
// the Free Software Foundation, either version 3 of the License, or
11+
// (at your option) any later version.
12+
//
13+
// This program is distributed in the hope that it will be useful,
14+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
// GNU Affero General Public License for more details.
17+
//
18+
// You should have received a copy of the GNU Affero General Public License
19+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
//
21+
22+
package org.astermail.android.ui.compose
23+
24+
import androidx.compose.foundation.layout.Arrangement
25+
import androidx.compose.foundation.layout.Column
26+
import androidx.compose.foundation.layout.FlowRow
27+
import androidx.compose.foundation.layout.fillMaxWidth
28+
import androidx.compose.material3.Button
29+
import androidx.compose.material3.Text
30+
import androidx.compose.material3.TextButton
31+
import androidx.compose.runtime.Composable
32+
import androidx.compose.runtime.getValue
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.remember
35+
import androidx.compose.runtime.setValue
36+
import androidx.compose.ui.Alignment
37+
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.platform.testTag
39+
import androidx.compose.ui.test.assertTextEquals
40+
import androidx.compose.ui.test.junit4.createComposeRule
41+
import androidx.compose.ui.test.onNodeWithTag
42+
import androidx.compose.ui.test.onNodeWithText
43+
import androidx.compose.ui.test.performClick
44+
import androidx.compose.ui.unit.dp
45+
import androidx.test.ext.junit.runners.AndroidJUnit4
46+
import org.astermail.android.design.AsterTheme
47+
import org.junit.Assert.assertEquals
48+
import org.junit.Assert.assertNull
49+
import org.junit.Rule
50+
import org.junit.Test
51+
import org.junit.runner.RunWith
52+
53+
//
54+
// Exercises the REAL production guard (reply_from_mismatch in
55+
// reply_from_resolver.kt) wired through the same dialog pattern
56+
// ComposeScreen uses: do_send gate + AsterDialog with Cancel /
57+
// Send anyway / Use received address text actions.
58+
//
59+
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
60+
@RunWith(AndroidJUnit4::class)
61+
class ReplyFromMismatchDialogTest {
62+
63+
@get:Rule
64+
val compose_rule = createComposeRule()
65+
66+
private val primary = "me@astermail.org"
67+
private val received_alias = "testing7363672g@aster.cx"
68+
69+
@Composable
70+
private fun guard_harness(
71+
mode: String,
72+
received_on_alias: String?,
73+
initial_from: String,
74+
on_send: (String) -> Unit,
75+
) {
76+
var from_alias by remember { mutableStateOf(initial_from) }
77+
var show_dialog by remember { mutableStateOf(false) }
78+
79+
fun do_send(skip_from_guard: Boolean = false) {
80+
if (!skip_from_guard && reply_from_mismatch(mode, received_on_alias, from_alias)) {
81+
show_dialog = true
82+
return
83+
}
84+
on_send(from_alias)
85+
}
86+
87+
Column {
88+
Text(from_alias, modifier = Modifier.testTag("from_value"))
89+
Button(
90+
modifier = Modifier.testTag("send"),
91+
onClick = { do_send() },
92+
) { Text("send") }
93+
}
94+
95+
if (show_dialog) {
96+
val received_address = received_on_alias.orEmpty()
97+
org.astermail.android.design.components.AsterDialog(
98+
on_dismiss = { show_dialog = false },
99+
title = "Reply from a different address?",
100+
message = "received on $received_address, sending from $from_alias",
101+
footer = {
102+
FlowRow(
103+
modifier = Modifier.fillMaxWidth(),
104+
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
105+
) {
106+
TextButton(
107+
modifier = Modifier.testTag("mismatch_cancel"),
108+
onClick = { show_dialog = false },
109+
) { Text("Cancel") }
110+
TextButton(
111+
modifier = Modifier.testTag("mismatch_send_anyway"),
112+
onClick = {
113+
show_dialog = false
114+
do_send(skip_from_guard = true)
115+
},
116+
) { Text("Send anyway") }
117+
TextButton(
118+
modifier = Modifier.testTag("mismatch_use_received"),
119+
onClick = {
120+
show_dialog = false
121+
if (received_address.isNotBlank()) from_alias = received_address
122+
do_send(skip_from_guard = true)
123+
},
124+
) { Text("Use received address") }
125+
}
126+
},
127+
)
128+
}
129+
}
130+
131+
@Test
132+
fun mismatched_reply_blocks_send_and_shows_dialog() {
133+
var sent: String? = null
134+
compose_rule.setContent {
135+
AsterTheme {
136+
guard_harness("reply", received_alias, primary, on_send = { sent = it })
137+
}
138+
}
139+
compose_rule.onNodeWithTag("send").performClick()
140+
compose_rule.waitForIdle()
141+
142+
assertNull(sent)
143+
compose_rule.onNodeWithText("Reply from a different address?").assertExists()
144+
compose_rule.onNodeWithTag("mismatch_cancel").assertExists()
145+
compose_rule.onNodeWithTag("mismatch_send_anyway").assertExists()
146+
compose_rule.onNodeWithTag("mismatch_use_received").assertExists()
147+
}
148+
149+
@Test
150+
fun cancel_closes_dialog_without_sending() {
151+
var sent: String? = null
152+
compose_rule.setContent {
153+
AsterTheme {
154+
guard_harness("reply", received_alias, primary, on_send = { sent = it })
155+
}
156+
}
157+
compose_rule.onNodeWithTag("send").performClick()
158+
compose_rule.onNodeWithTag("mismatch_cancel").performClick()
159+
compose_rule.waitForIdle()
160+
161+
assertNull(sent)
162+
compose_rule.onNodeWithText("Reply from a different address?").assertDoesNotExist()
163+
}
164+
165+
@Test
166+
fun use_received_switches_from_and_sends() {
167+
var sent: String? = null
168+
compose_rule.setContent {
169+
AsterTheme {
170+
guard_harness("reply", received_alias, primary, on_send = { sent = it })
171+
}
172+
}
173+
compose_rule.onNodeWithTag("send").performClick()
174+
compose_rule.onNodeWithTag("mismatch_use_received").performClick()
175+
compose_rule.waitForIdle()
176+
177+
assertEquals(received_alias, sent)
178+
compose_rule.onNodeWithTag("from_value").assertTextEquals(received_alias)
179+
}
180+
181+
@Test
182+
fun send_anyway_sends_from_selected_address() {
183+
var sent: String? = null
184+
compose_rule.setContent {
185+
AsterTheme {
186+
guard_harness("reply", received_alias, primary, on_send = { sent = it })
187+
}
188+
}
189+
compose_rule.onNodeWithTag("send").performClick()
190+
compose_rule.onNodeWithTag("mismatch_send_anyway").performClick()
191+
compose_rule.waitForIdle()
192+
193+
assertEquals(primary, sent)
194+
}
195+
196+
@Test
197+
fun matching_from_sends_without_dialog() {
198+
var sent: String? = null
199+
compose_rule.setContent {
200+
AsterTheme {
201+
guard_harness("reply", received_alias, received_alias, on_send = { sent = it })
202+
}
203+
}
204+
compose_rule.onNodeWithTag("send").performClick()
205+
compose_rule.waitForIdle()
206+
207+
assertEquals(received_alias, sent)
208+
compose_rule.onNodeWithText("Reply from a different address?").assertDoesNotExist()
209+
}
210+
211+
@Test
212+
fun forward_mode_never_guards() {
213+
var sent: String? = null
214+
compose_rule.setContent {
215+
AsterTheme {
216+
guard_harness("forward", received_alias, primary, on_send = { sent = it })
217+
}
218+
}
219+
compose_rule.onNodeWithTag("send").performClick()
220+
compose_rule.waitForIdle()
221+
222+
assertEquals(primary, sent)
223+
}
224+
}

0 commit comments

Comments
 (0)