Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 | ||
|
|
dfaf1f3e5e | ||
|
|
97c4f58379 | ||
|
|
5dfe869acb | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
# Fail in seconds instead of after a 20-minute build. There is no
|
||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||
@@ -249,6 +251,7 @@ jobs:
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING=""
|
||||
@@ -266,10 +269,18 @@ jobs:
|
||||
{
|
||||
echo "files<<FILES_EOF"
|
||||
echo "$DEST"
|
||||
echo "$DEST.sha256"
|
||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||
echo "FILES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ties the APK to this commit and workflow run, which the signing key
|
||||
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.export.outputs.version }}
|
||||
uploaded: ${{ steps.upload.outputs.done }}
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
@@ -254,6 +257,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
id: upload
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
@@ -271,4 +275,35 @@ jobs:
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "done=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
|
||||
# Puts the upload on the public TestFlight link (README): waits for App Store
|
||||
# Connect to process it, adds it to the external group and submits it for
|
||||
# beta review. On Linux, because most of this job is waiting.
|
||||
public-testflight:
|
||||
needs: build
|
||||
if: needs.build.outputs.uploaded == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Add the build to the public group and submit it for beta review
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import {
|
||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
|
||||
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
@@ -43,10 +44,10 @@ import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import EmailListScreen from './src/screens/EmailListScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsScreen from './src/screens/ContactsScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
@@ -57,12 +58,12 @@ import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useLayout } from './src/lib/use-layout';
|
||||
import { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
@@ -119,9 +120,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
emailIds: [payload.emailId],
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,9 +244,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
settings: t('sidebar.settings', 'Settings'),
|
||||
};
|
||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
||||
// Side by side layouts move the tabs into a rail on the left, like the
|
||||
// webmail's navigation rail.
|
||||
const { isSplit } = useLayout();
|
||||
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
|
||||
// with the tabs in a side rail the whole tab area has to.
|
||||
const bottomInset = useSafeAreaInsets().bottom;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
@@ -253,9 +262,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
headerShown: false,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
freezeOnBlur: true,
|
||||
tabBarPosition: isSplit ? 'left' : 'bottom',
|
||||
tabBarVariant: isSplit ? 'material' : 'uikit',
|
||||
// A narrow rail (icon over label), not a wide sidebar with labels
|
||||
// beside the icons.
|
||||
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: {
|
||||
tabBarStyle: isSplit ? {
|
||||
backgroundColor: c.secondary,
|
||||
borderRightColor: c.border,
|
||||
borderRightWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
shadowColor: 'transparent',
|
||||
} : {
|
||||
backgroundColor: c.background,
|
||||
borderTopColor: c.border,
|
||||
borderTopWidth: 1,
|
||||
@@ -289,30 +310,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<EmailListScreen
|
||||
onComposePress={() => navigation.navigate('Compose')}
|
||||
onEmailPress={(email) => {
|
||||
// The list holds the open folder's mail: name its account so the
|
||||
// viewer never has to guess it from whatever folder is open.
|
||||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||||
const target = {
|
||||
jmapAccountId: mailboxAccountId(all, currentMailboxId),
|
||||
// A row of an "All folders" list or a tag view names its own
|
||||
// account instead, and pages over that account's rows.
|
||||
...viewerParamsForRow(email),
|
||||
};
|
||||
// Start on the body now: mounting the viewer takes a while.
|
||||
prefetchMessage(email, target.jmapAccountId);
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
...target,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{() => <MailTab navigation={navigation} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
@@ -331,7 +329,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
@@ -400,6 +398,7 @@ export default function App() {
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
// JMAP session comes up in the background.
|
||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||
const { isSplit } = useLayout();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasRestoredSession) {
|
||||
@@ -830,10 +829,11 @@ export default function App() {
|
||||
<Stack.Screen
|
||||
name="Compose"
|
||||
component={ComposeScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
// A tablet shows the composer as a panel over the screen it came
|
||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||
options={isSplit
|
||||
? { presentation: 'transparentModal', animation: 'fade' }
|
||||
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## Try it
|
||||
|
||||
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
|
||||
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
@@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -46,4 +51,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -74,7 +74,9 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
thread(name = "bulwark-notification") {
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
@@ -14,6 +14,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ object NotificationTapStore {
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +79,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -1,7 +1,7 @@
|
||||
<resources>
|
||||
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#09090b</color>
|
||||
<color name="iconBackground">#db2d54</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">22dp</dimen>
|
||||
<dimen name="widget_radius">16dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -31,7 +31,7 @@ module.exports = {
|
||||
// Android's manifest is maintained by hand and lists the same schemes.
|
||||
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
orientation: 'default',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
@@ -58,7 +58,7 @@ module.exports = {
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#09090b',
|
||||
backgroundColor: '#db2d54',
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
predictiveBackGestureEnabled: false,
|
||||
@@ -99,6 +99,9 @@ module.exports = {
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 32 KiB |
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
The workflow's `public-testflight` job does the external part on its own. It
|
||||
waits for processing, adds the build to the `external` group (the one behind
|
||||
the public link in the README), sets "What to Test" to the GitHub release and
|
||||
submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by
|
||||
hand for a build, for example after a failed run:
|
||||
|
||||
```bash
|
||||
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number
|
||||
```
|
||||
|
||||
If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool
|
||||
reports it as "Cannot determine the Apple ID from Bundle ID"), the Account
|
||||
Holder has to accept an updated agreement under Business in App Store Connect.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
# Native UI and UX audit, 2026-10
|
||||
|
||||
Scope: every screen, sheet, dialog, banner and gesture of the Android app, on a phone and a tablet, in
|
||||
portrait and landscape. Started from `e19885d` (0.1.75).
|
||||
|
||||
## Method
|
||||
|
||||
- Devices (emulators):
|
||||
- phone `Bulwark_Pixel7_API35`, 411x914 dp;
|
||||
- tablet `Pixel_Tablet_API_34`, 1280x800 dp.
|
||||
|
||||
The tablet's natural orientation is landscape, so `user_rotation 1` is tablet portrait.
|
||||
- Cells used below:
|
||||
|
||||
| Cell | Device and orientation |
|
||||
|---|---|
|
||||
| PP | phone portrait |
|
||||
| PL | phone landscape |
|
||||
| TL | tablet landscape |
|
||||
| TP | tablet portrait |
|
||||
|
||||
"all" means all four.
|
||||
- Build: release APK from `C:\rn`, with the portrait lock removed for the audit only.
|
||||
- Data: Stalwart 0.16 on VM 106, seeded with:
|
||||
- 520+ inbox mails, a 16-message thread, HTML-only newsletters, a wide table, all attachment types
|
||||
and an inline cid image;
|
||||
- RTL mail (Arabic, Hebrew), a read-receipt request, very long subjects and names;
|
||||
- nested folders;
|
||||
- recurring, all-day, multi-day, overlapping, declined and cancelled events, and tasks;
|
||||
- 65 contacts with and without photos, plus two groups;
|
||||
- files and folders, and an active vacation reply.
|
||||
|
||||
The phone ran as `usera@`, the tablet as a separate `uxaudit@` account with the same seed.
|
||||
- Every area was captured in all four cells in light English (tablet) and dark English (phone).
|
||||
Then a second pass ran with German and font scale 1.3, on PP and TL, with the tablet in dark.
|
||||
- Four read-only code passes (mail list, thread and compose, calendar, contacts/files/settings/login)
|
||||
gave the `file:line` references. Device findings were checked against them.
|
||||
- Screenshots are in the session scratchpad (`shots/q_<screen>.png`, a 4-cell strip: PP, PL, TL, TP;
|
||||
`shots/d_<screen>.png` for the German/1.3 pass). They are not committed.
|
||||
|
||||
## Inventory
|
||||
|
||||
### Navigation
|
||||
|
||||
- Root native stack (`App.tsx`):
|
||||
- MainTabs
|
||||
- EmailThread
|
||||
- EmailSource
|
||||
- Compose (modal)
|
||||
- ContactDetail
|
||||
- ContactForm (modal)
|
||||
- GroupDetail
|
||||
- Scheduled
|
||||
- UnifiedInbox
|
||||
- AddAccount (modal)
|
||||
- Bottom tabs: Mail, Calendar, Contacts, Files, Settings. A tab the server lacks is dimmed and does
|
||||
nothing.
|
||||
- Above the tabs:
|
||||
- UpdateBanner
|
||||
- OfflineCacheBanner
|
||||
- PushOnboardingPrompt (floating card)
|
||||
- AppIconBadge (no UI)
|
||||
- On the focused stack screen: ToastHost and UndoSnackbar (none on Compose).
|
||||
- Entry points:
|
||||
- deep links: `bulwarkmobile://`, webmail permalinks, `mailto:`, and sign-in links
|
||||
(`bulwarkmail://pair`, `connect`);
|
||||
- Android share intents into Compose;
|
||||
- notification taps (mail push, calendar reminders);
|
||||
- widgets (inbox, agenda), which deep-link.
|
||||
|
||||
### Login (`LoginScreen` + `screens/login/*`)
|
||||
|
||||
- Steps: choose → email → (discovery) confirm or server → confirm → OAuth hand-off in the browser, or
|
||||
password (+ TOTP when the server asks) → signing in.
|
||||
- Side paths:
|
||||
- QR scan modal (camera permission, blocked → open settings);
|
||||
- paste-link modal;
|
||||
- external sign-in link confirm alert;
|
||||
- refused-link notice;
|
||||
- Add account mode with a close button.
|
||||
|
||||
### Mail
|
||||
|
||||
- **Mail list** (`EmailListScreen`):
|
||||
- Header: menu, folder title, import, logo.
|
||||
- Search row: select-all, search with recent searches and people suggestions, sort toggle,
|
||||
filter.
|
||||
- Filter chips.
|
||||
- Banners: empty folder, offline, failed outbox, unreachable accounts.
|
||||
- States: loading, error + retry, no mailboxes, empty / no results.
|
||||
- Gestures: pull to refresh, endless scroll, long-press to select, swipe actions (instant or
|
||||
reveal).
|
||||
- Selection header: star, read, tag, move, spam, archive, delete.
|
||||
- Compose FAB.
|
||||
- Sheets: filter modal (with date pickers), MoveSheet, TagSheet, attachment preview.
|
||||
- **Sidebar drawer** (`SidebarDrawer`):
|
||||
- Account switcher and account menu: add, set default, sign out, sign out of all.
|
||||
- Unified Inbox, Scheduled.
|
||||
- Unified Mailbox section, folders tree with unread counts and long-press folder sheet, shared
|
||||
accounts, tags.
|
||||
- Nested ActionSheet and NamePrompt.
|
||||
- **Unified inbox** (`UnifiedInboxScreen`): its own header, search, selection, swipe, account dots,
|
||||
pull to refresh, load more.
|
||||
- **Scheduled** (`ScheduledScreen`): list, per-item actions (send now, reschedule, edit, cancel),
|
||||
reschedule presets, custom date-time picker.
|
||||
|
||||
### Thread view (`EmailThreadScreen` + `components/email/*`)
|
||||
|
||||
- Layout:
|
||||
- Horizontal pager over the list.
|
||||
- Toolbar: back, delete, archive (≥400 dp), unread (≥340 dp), star, more.
|
||||
- Subject with star.
|
||||
- Single message, or conversation cards (collapsed rows, expanded cards with Reply / Reply all /
|
||||
Forward pills).
|
||||
- Message header: sender and recipients open the address sheet; "Show details".
|
||||
- Attachments: preview, open, save and share; "Download all".
|
||||
- Banners: blocked images, truncated body, read receipt, unsubscribe, calendar invitation (RSVP,
|
||||
add to calendar).
|
||||
- Quick reply box.
|
||||
- Bottom bar: Prev, three configurable actions, Next.
|
||||
- More sheet: archive, read, move, tag, spam, view mode, sender, forward as attachment, source,
|
||||
export.
|
||||
- Email source screen.
|
||||
|
||||
### Compose (`ComposeScreen`)
|
||||
|
||||
- Header: close, title + draft status, attach, schedule, send.
|
||||
- Fields:
|
||||
- From (IdentitySheet, From options);
|
||||
- To / Cc / Bcc with chips, autocomplete and a chip menu;
|
||||
- Subject.
|
||||
- Attachments: sheet with photos, camera, files; chips with progress.
|
||||
- Body: rich editor WebView or plain text.
|
||||
- Format bar: templates, read receipt, plain/rich, B/I/U/S, colours, H1/H2, lists, quote, table,
|
||||
alignment, link, image, clear, undo/redo.
|
||||
- Modals: link, colour palette, table, schedule sheet, date-time pickers, template placeholders,
|
||||
TemplateSheet.
|
||||
- Flows:
|
||||
- draft autosave;
|
||||
- close guard (cancel / discard / save draft);
|
||||
- empty-subject and forgotten-attachment checks;
|
||||
- undo send through UndoSnackbar;
|
||||
- scheduled-send confirmation.
|
||||
|
||||
### Calendar (`CalendarScreen` + `components/calendar/*`)
|
||||
|
||||
- Views: month (free scroll or paged), week (free scroll or paged), 3 days, day, schedule (agenda).
|
||||
- Header: menu, month title with month dropdown, prev/next (paged only), today, tasks.
|
||||
- FAB: event, or a menu with Event / Task.
|
||||
- Sheets and dialogs:
|
||||
- EventDetailSheet: RSVP, edit, duplicate, .ics, link, delete.
|
||||
- EventModal (editor): RecurrenceEditor, ParticipantInput, pickers.
|
||||
- RecurrenceScopeDialog.
|
||||
- CalendarSidebarDrawer: views, refresh, calendars with a long-press panel, new calendar,
|
||||
subscriptions, import.
|
||||
- TasksSheet, ICalImportSheet, ICalSubscriptionSheet, CalendarEditSheet, CalendarShareSheet.
|
||||
- Gestures:
|
||||
- tap a slot for a draft, then tap the draft to create;
|
||||
- long-press a slot to create;
|
||||
- tap a month cell to open the day;
|
||||
- free scroll with snapping.
|
||||
- There is no drag-to-move or drag-to-resize, so there is nothing to break mid-drag.
|
||||
|
||||
### Contacts
|
||||
|
||||
- `ContactsScreen`:
|
||||
- Header: drawer, count, search, import, add (long-press: contact or group).
|
||||
- Address book / group chips.
|
||||
- Long-press to select.
|
||||
- Selection header: select all, export, move, tag, delete.
|
||||
- Pull to refresh.
|
||||
- `ContactsSidebarDrawer`: books, shared books, groups, tags with rename.
|
||||
- `ContactDetailScreen`:
|
||||
- hero and quick actions (email, call, SMS, share);
|
||||
- sections: email, phone, address, work, personal, online, keys, categories, related, members,
|
||||
notes, address book;
|
||||
- recent mail and events;
|
||||
- More sheet (duplicate, add to group, move, export, delete).
|
||||
- `ContactFormScreen`: person, organisation or group, with many collapsible sections and a date
|
||||
picker.
|
||||
- `GroupDetailScreen`: members, add or remove.
|
||||
- Sheets: AddressBookPicker, TagAssign, ContactPicker, ContactImport.
|
||||
|
||||
### Files (`FilesScreen`)
|
||||
|
||||
- Header: back, search, new folder, upload, grid/list.
|
||||
- Breadcrumb, sort chip, upload progress.
|
||||
- List or 3-column grid; long-press to select (download zip, delete).
|
||||
- Per-item ⋮ sheet: preview, open with, download, share, move, duplicate, rename, delete.
|
||||
- Folder picker, sort sheet, prompt modal, share sheet, preview modal.
|
||||
|
||||
### Settings (`SettingsScreen` + `components/settings/*`)
|
||||
|
||||
- One screen that swaps between a searchable section list and a page.
|
||||
- Pages:
|
||||
- account, language, notifications
|
||||
- appearance, layout
|
||||
- reading, composing, identities, vacation, filters (rule modal, sieve editor), templates,
|
||||
folders, tags, downloads
|
||||
- security, encryption (disabled), content & senders
|
||||
- calendar and contacts (with device sync), files, sidebar apps
|
||||
- about & data, themes, plugins, updates
|
||||
- Sign out.
|
||||
|
||||
### Other
|
||||
|
||||
- PushOnboardingPrompt, OfflineBanner, OfflineCacheBanner, UpdateBanner.
|
||||
- Toasts: swipe to dismiss, action button.
|
||||
- UndoSnackbar: undo, send now.
|
||||
- Android widgets (out of scope for fixes): inbox and agenda.
|
||||
|
||||
## Findings
|
||||
|
||||
Severity: **P1** broken or blocking, **P2** clearly wrong or clumsy, **P3** polish. Each finding
|
||||
lists the cells where it occurs, the screenshot, the code location and the intended fix. A tick
|
||||
carries the commit that fixed it.
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
- [x] **U-001 · P1 · PL TL TP · The app is locked to portrait and has no large-screen layout.** *(fixed in bfa02f1)*
|
||||
- Where: `app.config.js:34`, `AndroidManifest.xml:28`.
|
||||
- Android 16 ignores the lock on screens 600 dp and wider, so tablets and foldables rotate anyway.
|
||||
- Fix: remove the lock in both places. Keep `configChanges` (and add `smallestScreenSize` for
|
||||
split-screen and foldables). Then do phase 2.
|
||||
- [x] **U-002 · P2 · iPad, landscape · No RN `<Modal>` sets `supportedOrientations`.** *(fixed in bfa02f1)*
|
||||
- Where: 56 modals in 40 files.
|
||||
- iOS presents these portrait-only and forces a rotation.
|
||||
- Fix: pass all orientations everywhere.
|
||||
- [x] **U-003 · P2 · TL TP · Every screen is the phone layout stretched to 1280 dp.** *(fixed in 802474d, 64a62b6, 8ab82cd, 2470e99, 0087e4c, 250c0a9)*
|
||||
- Affected: mail rows, thread body, compose, contacts, files, settings, login.
|
||||
- Screenshots: `q_mail_list`, `q_thread_receipt`, `q_contacts`, `q_settings`, `t_login_confirm`.
|
||||
- Fix: the adaptive layout plan below.
|
||||
- [x] **U-004 · P2 · TL TP · Sheets and floating cards span the full width.** *(fixed in f4ff07d)*
|
||||
- Affected: bottom sheets, toasts, the undo bar and the push prompt.
|
||||
- Screenshots: `q_event_detail`, `q_mail_list` (push prompt).
|
||||
- Fix: on medium and expanded widths, cap them at about 560 dp and centre them.
|
||||
- [x] **U-005 · P2 · PL · Left and right safe-area insets are ignored.** *(fixed in bfa02f1, f4ff07d, e19229a)* *(partly fixed in bfa02f1 (compose), f4ff07d (sheets))*
|
||||
- Most screens use `SafeAreaView edges={['top']}` and position sheets `left: 0, right: 0`.
|
||||
- A side navigation bar or a camera cutout covers the content.
|
||||
- Fix: add the side edges to screen roots, and inset sheets and the FABs.
|
||||
- [x] **U-006 · P2 · all · The Files header pads a fixed 60 px instead of the status-bar inset.** *(fixed in cbc41c9)*
|
||||
- Where: `FilesScreen.tsx:1438`.
|
||||
- It is too tall in landscape and wrong on any other status bar height.
|
||||
- [x] **U-007 · P2 · all · Uppercase section labels get clipped on Android.** *(fixed in 9e463d3)*
|
||||
- Example: "WORK" shows as "WOR", "ADDRESS BOOK" as "ADDRESS" (contact detail, English).
|
||||
- Cause: Android measures a shrink-wrapped, letter-spaced label a few pixels short, so its
|
||||
last word wraps onto a second line that the one-line height hides. (Upper-casing the string
|
||||
instead of `textTransform` made no difference.)
|
||||
- Where: `ContactDetailScreen.tsx:976`, `ContactActivity.tsx:283`.
|
||||
- Fix: let the label fill its row (`flex: 1`).
|
||||
- [x] **U-008 · P2 · all · After a cold start in German, the tab labels and the folder title stay
|
||||
English.** *(fixed in 512a28c)* The search placeholder is German. Screenshot `t_layo`.
|
||||
- Fix: re-render the tab labels and the title when the locale store hydrates.
|
||||
- [x] **U-009 · P2 · TL TP (font 1.3) · Tab labels truncate with plenty of room.** *(fixed in 802474d)*
|
||||
- Example: "E-…", "Kalen…", "Einstellu…" in 256 dp-wide tabs (`q_de_mail`).
|
||||
- The label sits beside the icon with a fixed font size and no width.
|
||||
- Fix: let the label size itself; on wide screens use the side rail (phase 2).
|
||||
- [x] **U-010 · P2 · all (font 1.3) · The mail search field clips the top of its text.** *(fixed in 7b93265)*
|
||||
- Where: fixed height in `EmailListScreen.tsx:1885-1912`.
|
||||
- Fix: use `minHeight` and vertical padding.
|
||||
- [x] **U-011 · P2 · dark · The dark palette doesn't match the webmail's standard theme.** *(fixed in b3290c3)*
|
||||
- The app uses a blue primary and zinc greys. The webmail uses a white `#fafafa` primary with
|
||||
`#171717` text, a `#0a0a0a` ground, neutral greys, `#1c1c1c` popover and a `#262626` muted
|
||||
surface.
|
||||
- Where: `src/theme/tokens.ts` `DARK_COLORS`. APP.md lists this as open.
|
||||
- Fix: port the webmail dark tokens.
|
||||
- [x] **U-012 · P2 · light · The mail header logo is invisible on the light theme.** *(fixed in 99de74d)*
|
||||
- It always loads `Bulwark Logo White.png` (`EmailListScreen.tsx:1105`). Screenshot `q_mail_list`
|
||||
TL.
|
||||
- Fix: pick the logo by the resolved theme.
|
||||
- [x] **U-013 · P2 · light · TagSheet reads the static dark palette.** *(fixed in 29c172f)*
|
||||
- Where: `TagSheet.tsx:7,101,124`. Tag colours are wrong on light.
|
||||
- Fix: use `c.tags`.
|
||||
- [x] **U-014 · P2 · all · `Button` has no `accessibilityRole` and no disabled or busy state.** *(fixed in f891bf3)*
|
||||
- Where: `Button.tsx:35`. This affects every dialog and prompt button.
|
||||
- [x] **U-015 · P2 · all · Many icon buttons are under 48 dp.** *(fixed in c8434b0,776447d)* *(partly fixed in c8434b0 (thread back, login header, Files header and row menu, account icons, switches, drawer chevron and folder icon, search clear, recipient chip, quick reply))*
|
||||
- Mail search clear 36, recent-search remove 28, filter chip remove 32, attachment chips about 22,
|
||||
drawer chevron 32.
|
||||
- Viewer: card pills about 36, collapse handle 18, quick-reply Send 28, subject star 34.
|
||||
- Compose: recipient chip X 28, Send 36, Cc/Bcc toggles 28.
|
||||
- Files: header 44, row ⋮ 26.
|
||||
- Settings: account icon buttons 28, toggle 24 tall.
|
||||
- Login: header 32, show-password 20.
|
||||
- Sheet close buttons are 28 everywhere.
|
||||
- Lists with `file:line` are in the code passes; the fix commits name the places.
|
||||
- Fix: a shared 48 dp target (box plus `hitSlop`), applied screen by screen.
|
||||
- [ ] **U-016 · P3 · all · Sheet and dialog scrims are a literal `rgba(0,0,0,0.5)` in about 20
|
||||
files.** *(deferred: all scrims are already the same black at 0.5 on both themes; a token is a refactor with no visible change)* Fix: an `overlay` token.
|
||||
- [ ] **U-017 · P3 · all · Some confirmations use the system alert, others the app's `Dialog`.** *(deferred: switching every confirmation to Dialog changes a lot of flows for a style difference only; see product questions)*
|
||||
Examples: compose discard, event delete, folder empty. This is a style mismatch.
|
||||
- [ ] **U-018 · P3 · all · Changing the system font size while the app is open recreates the activity
|
||||
and resets navigation to the mail list.** *(deferred: handling fontScale in the app (configChanges) was tried and left a mix of old and new text sizes until a restart, worse than Android's default recreate; needs RN to re-measure on a font scale change)* Text measured before the change can stay clipped until
|
||||
the next restart ("Layo" for "Layout").
|
||||
|
||||
### Mail list, drawer, unified inbox, scheduled
|
||||
|
||||
- [x] **U-020 · P2 · all · Back with an active search exits the app instead of clearing the
|
||||
search.** *(fixed in 46d3016)*
|
||||
- Repro: search "Offsite", press Enter, then Back.
|
||||
- Where: `EmailListScreen.tsx` has no back handler for search or selection.
|
||||
- Fix: `useBackWhileFocused` that clears the selection, then the search.
|
||||
- [x] **U-021 · P2 · PL · Phone landscape shows about 1.5 rows.** *(fixed in b5c4d62)*
|
||||
- The 56 dp header, the 56 dp search row and the 64 dp tab bar take most of the 411 dp.
|
||||
- The compose FAB covers the second row. Screenshot `q_mail_list` PL.
|
||||
- Fix: in compact height, merge the title and search into one row and drop the tab labels.
|
||||
- [x] **U-022 · P2 · PL (font 1.3) · Rows cut the sender to three letters and clip the time.** *(no change needed: after a restart at 1.3 the rows keep the full time and name; the clipping was stale measurement from changing the font size live, see U-018)*
|
||||
- Example: "Us…", "17:4" (`q_de_mail` PL).
|
||||
- Where: the row header in `EmailListScreen.tsx` around 158-250.
|
||||
- Fix: let the time keep its width (`flexShrink: 0`) and let the name shrink.
|
||||
- [x] **U-023 · P2 · PP · The undo bar sits on top of the compose FAB for 5 s after every archive or
|
||||
delete.** *(fixed in 50ab714)*
|
||||
- Where: `UndoSnackbar.tsx:161`, `EmailListScreen.tsx:2262`.
|
||||
- Fix: lift the FAB while the bar shows.
|
||||
- [x] **U-024 · P2 · all · Swipe and bulk actions on the list fail silently.** *(fixed in 0db4bf0)*
|
||||
- Archive, spam, read, star, move and delete are called as `void ...` with no catch, so a server
|
||||
rejection becomes an unhandled promise. The selection also stays.
|
||||
- Where: `EmailListScreen.tsx:604-651, 715-799`; `handleImport` `:492`.
|
||||
- Fix: report failures through `toast.error` as the viewer does.
|
||||
- [x] **U-025 · P2 · all · Sign out and "Sign out of all accounts" run with no confirmation.** *(fixed in 8403843)*
|
||||
- Where: drawer `SidebarDrawer.tsx:930,943`, settings `SettingsScreen.tsx:497`.
|
||||
- Fix: a destructive `Dialog`.
|
||||
- [x] **U-026 · P2 · all · The drawer's folder-settings icon only closes the drawer.** *(fixed in d97571c)*
|
||||
- Where: `SidebarDrawer.tsx:1048`, `navigate('MainTabs')`.
|
||||
- Fix: open Settings → Folders.
|
||||
- [x] **U-027 · P2 · all · The filter modal runs a query per keystroke and has no keyboard
|
||||
handling.** *(fixed in c6cb44d)*
|
||||
- Where: `EmailListScreen.tsx:1507-1550`.
|
||||
- Fix: debounce like the main search; add next/search return keys.
|
||||
- [x] **U-028 · P2 · PP PL · The unified inbox and Scheduled lists end under the gesture bar.** *(fixed in 984c359)*
|
||||
- Where: `UnifiedInboxScreen.tsx:384`, `ScheduledScreen.tsx:278` (top edge only, fixed padding).
|
||||
- [x] **U-029 · P2 · all · Unified inbox rows ignore the font-size, density and preview settings.** *(fixed in 52eb18d)*
|
||||
- Where: `UnifiedInboxScreen.tsx:576`.
|
||||
- Its load and open failures are silent (`:103-112`, `:179-206`).
|
||||
- [x] **U-030 · P3 · all · The menu button keeps a grey focus box after the drawer closes.** *(no change needed: did not reproduce after the drawer and rail changes; the button shows no focus box after closing the drawer by tap or Back)*
|
||||
Screenshot `q_search_results`.
|
||||
- [x] **U-031 · P3 · PP · The selection header holds up to 7 buttons; its count has no
|
||||
`numberOfLines`.** *(fixed in 0d96d8d)* Where: `EmailListScreen.tsx:982-1066`.
|
||||
- [x] **U-032 · P3 · all · Folders under a hidden empty parent show flattened at the top level.** *(no change needed: the tree only lifts a folder whose parent the server does not return; usera has no Clients folder, and on uxaudit Q4 nests under Clients / ACME / 2026)*
|
||||
- Example: "Q4 contracts" without Clients / ACME / 2026 (`q_drawer` PP).
|
||||
- This happens with "hide empty folders" on. To check before changing anything.
|
||||
|
||||
- [x] **U-033 · P2 · TL TP · With the tabs in the side rail, the compose FAB and list ends sit under
|
||||
the tablet taskbar.** *(fixed in b9e6ba4)* Found after the mail split view landed (802474d): a bottom tab bar used to
|
||||
keep the screens clear of the bottom inset, a side rail doesn't.
|
||||
- Fix: pad the tab area by the bottom inset when the rail is shown.
|
||||
### Thread view
|
||||
|
||||
- [x] **U-040 · P1 · PL TL TP · After a rotation the pager is misaligned.** *(fixed in bfa02f1)*
|
||||
- Half of the neighbouring page shows and the open message is cut off at the right edge.
|
||||
- It happens when rotating on the thread, and when rotating on Compose opened from it
|
||||
(`q_thread_long` PL, `q_compose_close` TL).
|
||||
- Where: `EmailThreadScreen.tsx:265-280`; the pager keeps its old pixel offset.
|
||||
- Fix: re-scroll to the active index after a width change, without animation.
|
||||
- [x] **U-041 · P2 · all · A long thread opens at the oldest message.** *(fixed in 7617064)*
|
||||
- The newest (opened) message is expanded but off screen on a phone (`q_thread_long` PP).
|
||||
- Fix: scroll the opened message into view once the cards are laid out.
|
||||
- [x] **U-042 · P1 · all · Quick reply "More options" throws away the typed text.** *(fixed in 053fde8)*
|
||||
- Where: `EmailThreadScreen.tsx:1185`.
|
||||
- Fix: pass the text as `prefillBody` to the reply composer.
|
||||
- [x] **U-043 · P2 · PL · Phone landscape leaves almost no room for the body.** *(fixed in 975ed08)*
|
||||
- Toolbar with labels, subject, sender block, attachments, banner and the labelled bottom bar
|
||||
fill the 411 dp (`q_thread_receipt` PL).
|
||||
- Fix: icon-only toolbar and bottom bar in compact height.
|
||||
- [x] **U-044 · P2 · PP (German, 1.3) · The toolbar overflows and pushes More off screen.** *(fixed in 975ed08)*
|
||||
- "Stern hinzufügen" is long (`d_de_thread`).
|
||||
- Fix: drop the toolbar labels when they don't fit; keep the a11y labels.
|
||||
- [ ] **U-045 · P3 · German · Prev/Next read "Zurück"/"Weiter".** *(deferred: the strings come from the vendored webmail catalog (locales/de/common.json); the fix belongs in the webmail's German translation)*
|
||||
- "Zurück" reads as Back. "Allen antworten" wraps in the bottom bar.
|
||||
- Fix: "Vorherige"/"Nächste" (locale), and a single line with shrinking text.
|
||||
- [x] **U-046 · P2 · all · The tag sheet doesn't scroll.** *(fixed in b59d00f)*
|
||||
- With many tags, or in landscape, rows can't be reached.
|
||||
- Where: `EmailThreadScreen.tsx:1472-1486`.
|
||||
- [x] **U-047 · P2 · TL TP · Plain-text and fluid HTML bodies run 150+ characters per line.** *(fixed in e94b782)*
|
||||
- Fix: a readable max width for the body on wide panes.
|
||||
- [x] **U-048 · P3 · all · The toolbar star always says "Star", even when the message is starred.** *(fixed in d7cee7c)*
|
||||
- Where: `EmailThreadScreen.tsx:746`.
|
||||
- [x] **U-049 · P3 · PL TL · Attachment image preview is sized from the window width, so it is
|
||||
taller than the screen in landscape.** *(fixed in 2470e99)* Where: `AttachmentPreviewModal.tsx:112`.
|
||||
|
||||
### Compose
|
||||
|
||||
- [x] **U-060 · P1 · PL · With the keyboard open in landscape, the whole compose body is blank.** *(fixed in bfa02f1)*
|
||||
- Only the header stays, so you can't see what you type (`tri.png`).
|
||||
- Where: the keyboard padding is computed from `Dimensions.get('screen').height`
|
||||
(`ComposeScreen.tsx:400-425`), which doesn't follow the rotation.
|
||||
- Fix: compute the overlap from the window, against the keyboard frame.
|
||||
- [x] **U-061 · P2 · all · An untouched reply autosaves a draft and asks "Save or discard?" on
|
||||
close.** *(fixed in e7857ef)*
|
||||
- The signature insertion counts as an edit (`d_de_compose`). Opening and closing a reply should
|
||||
leave nothing behind.
|
||||
- Where: `ComposeScreen.tsx:1116-1184`, `:1331-1444`.
|
||||
- [x] **U-062 · P2 · all · Recipient and subject fields have no return-key flow.** *(fixed in 56e1593)*
|
||||
- Fix: To/Cc/Bcc use `next`; an empty submit moves on; Subject `next` focuses the body.
|
||||
- Where: `ComposeScreen.tsx:2228-2395`.
|
||||
- [x] **U-063 · P2 · all (German, 1.3) · Field labels wrap.** *(fixed in 509583e)* "Betreff" shows as "Betref/f": the
|
||||
label column is a fixed width.
|
||||
- [x] **U-064 · P3 · PP · The subject field shows the end of a long subject.** *(fixed in 56e1593)*
|
||||
- Example: "…ning 2027: venue…". The cursor was put at the end on mount.
|
||||
- [x] **U-065 · P2 · all · Format-bar buttons have no accessibility labels.** *(fixed in af55e0a)*
|
||||
- Where: `ComposeScreen.tsx:2441-2513`; the toolbar button also has no role or state.
|
||||
- [x] **U-066 · P2 · PL · Text inputs use Android's full-screen landscape keyboard.** *(fixed in bfa02f1)*
|
||||
- The autocomplete and the composer are hidden behind it.
|
||||
- Fix: `disableFullscreenUI` on recipients, subject, plain body and quick reply.
|
||||
- [x] **U-067 · P2 · TL TP · Compose fills the tablet screen.** *(fixed in 0087e4c)* Fix: a centred panel (phase 2).
|
||||
|
||||
- [x] **U-068 · P2 · all · The signature shows two "--" lines.** *(fixed in 90170ea)* The seeded text signature starts
|
||||
with the usual "-- " delimiter and the composer adds its own separator above it (screenshot
|
||||
`t_compose_panel`).
|
||||
- Fix: don't add a separator to a signature that already starts with one.
|
||||
- [ ] **U-069 · P2 · PL · Recipient suggestions can't show in phone landscape.** *(deferred: with the keyboard up a phone in landscape has about 100 dp left; suggestions need a different design (a full-screen picker), which is a feature)* With the keyboard
|
||||
up, about 50 dp are left under the To field, so the suggestion list is hidden behind the
|
||||
keyboard. Typing a full address still works.
|
||||
### Calendar
|
||||
|
||||
- [x] **U-080 · P1 · PL · Month view shows two week rows and cuts off the rest.** *(fixed in bfa02f1)*
|
||||
- Rows keep a 72 dp minimum and the list is sized to six of them (`q_calendar_default` PL).
|
||||
- Where: `MonthScrollView.tsx:127-128,250`, `MonthView.tsx:427-438`.
|
||||
- Fix: size the list to the real height and let rows shrink.
|
||||
- [x] **U-081 · P1 · TL TP · Rotating on month or week jumps back a month, and week view goes
|
||||
blank.** *(fixed in bfa02f1)*
|
||||
- After a rotation, month view showed September instead of October. Week view showed no day
|
||||
headers and no events until Today was tapped (`q_calendar_default` TP, `q_cal_week` TL).
|
||||
- Where: `MonthScrollView` samples the visible month at a fixed 40% of a stale height;
|
||||
`TimeGridScrollView` keeps the old `scrollX` after its list remounts.
|
||||
- Fix: keep the focused date across the remount and reset the header offset.
|
||||
- [x] **U-082 · P1 · PL · The event sheet's action row (Edit, Duplicate, .ics, Delete) is below the
|
||||
screen.** *(fixed in bfa02f1)*
|
||||
- Screenshot `p_event_land`.
|
||||
- Where: `EventDetailSheet.tsx:609,643` (body `maxHeight: 400`, no `flexShrink`).
|
||||
- [x] **U-083 · P1 · all · Saving an event fails silently.** *(fixed in ab712f1)*
|
||||
- `handleSave` has `try/finally` without catch: the spinner stops, the editor stays, nothing is
|
||||
said.
|
||||
- Where: `EventModal.tsx:301-368`.
|
||||
- [x] **U-084 · P2 · all · Several deletes have no confirmation or undo.** *(fixed in 0460da5)*
|
||||
- Deleting a single event (`CalendarScreen.tsx:632`, `EventModal.tsx:371`).
|
||||
- Deleting a task (`TasksSheet.tsx:427`).
|
||||
- Unsubscribing from a feed, which also deletes its calendar (`ICalSubscriptionSheet.tsx:278`).
|
||||
- [x] **U-085 · P2 · all · Cancelling the recurrence-scope dialog after editing a recurring event
|
||||
throws the edits away.** *(fixed in 72b4f4a)* Where: `CalendarScreen.tsx:786-790`.
|
||||
- [x] **U-086 · P2 · TL TP · Wide screens get wider columns, not more days.** *(fixed in 2288d28)* 3 days stays 3 and
|
||||
day stays 1. Fix: phase 2.
|
||||
- [x] **U-087 · P2 · PL · The month dropdown is a fixed 346 dp tall and sized from the window.** *(fixed in bfa02f1)*
|
||||
- It overflows in landscape and lands on a half page after rotation.
|
||||
- Where: `MonthDropdown.tsx:23,52,72`.
|
||||
- [x] **U-088 · P3 · all · Times and dates use English formats in other languages.** *(fixed in cf7275c)*
|
||||
- Hour labels hard-code AM/PM (`TimeGridScrollView.tsx:437,640`, `WeekView.tsx:234,318`).
|
||||
- Dates use US-order patterns (`EventDetailSheet.tsx:96-108`, `EventModal.tsx:525,545`,
|
||||
`TasksSheet.tsx:212`).
|
||||
- The AM/PM labels only show with the 12-hour setting, where they are the right notation; left
|
||||
as is.
|
||||
- [x] **U-089 · P3 · all · ICalSubscriptionSheet and RecurrenceEditor are English only.** *(no change needed: both components already go through t() and every key has a German entry; the parity doc is out of date)* From
|
||||
`docs/parity/05-calendar.md`.
|
||||
- [x] **U-090 · P3 · PP (font 1.3) · Month chips show only the start time, no title.** *(fixed in 95c3163)*
|
||||
- [x] **U-091 · P3 · all · An event that starts above the visible hours shows as an untitled
|
||||
block.** *(no change needed: this is a block scrolled partly out of the top of the grid, so its title is above the viewport; Google Calendar and Apple Calendar behave the same)*
|
||||
|
||||
### Contacts
|
||||
|
||||
- [x] **U-100 · P1 · all · Deleting a contact or group crashes the detail screen.** *(fixed in 295e292)*
|
||||
- Hooks run after an early return: `ContactDetailScreen.tsx:149` before `:216`, and
|
||||
`GroupDetailScreen.tsx:58` before `:130`.
|
||||
- The delete empties the store, the screen re-renders with fewer hooks, and React throws. A
|
||||
contact removed by a sync does the same.
|
||||
- [x] **U-101 · P2 · PL · The address-book chip row is clipped at the bottom.** *(fixed in 0b37102)* Screenshot
|
||||
`q_contacts` PL.
|
||||
- [x] **U-102 · P2 · all · Back doesn't leave selection or search; it leaves the tab.** *(fixed in 756ceaa)*
|
||||
- Where: `ContactsScreen.tsx`. Files and Settings already handle this.
|
||||
- [x] **U-103 · P2 · all · The contact form's discard check only covers the on-screen back.** *(fixed in 1ebc5c6)*
|
||||
- Hardware back drops edits silently.
|
||||
- Where: `ContactFormScreen.tsx:764`. Fix: `usePreventRemove`.
|
||||
- [x] **U-104 · P2 · all · Contact form fields have no next or submit chaining.** *(fixed in 33dc3cd)* Where:
|
||||
`ContactFormScreen.tsx:1197-1283`.
|
||||
- [x] **U-105 · P2 · TL TP · List and detail are separate full-screen pages.** *(fixed in 64a62b6)* Fix: phase 2.
|
||||
|
||||
### Files
|
||||
|
||||
- [x] **U-110 · P2 · all · The grid has a fixed 3 columns.** *(fixed in 2470e99)*
|
||||
- The last row stretches its cells, so the columns don't line up. Tablet cells are huge
|
||||
(`q_files_grid`).
|
||||
- Where: `FilesScreen.tsx:989,1606`.
|
||||
- [x] **U-111 · P2 · all · Dates and times ignore the app's language and the 12/24 h setting.** *(fixed in 63eefd7)*
|
||||
- Example: "10:47 PM" next to 24 h times everywhere else.
|
||||
- Where: `FilesScreen.tsx:127-132`.
|
||||
- [x] **U-112 · P2 · all · Removing someone's share access has no confirmation.** *(fixed in d4982f6)* Where:
|
||||
`ShareSheet.tsx:213`.
|
||||
- [x] **U-113 · P2 · TL TP · There is no side-by-side preview.** *(fixed in 2470e99)* Fix: phase 2.
|
||||
|
||||
### Settings
|
||||
|
||||
- [x] **U-120 · P2 · all · Deleting a tag has no confirmation, while the reset next to it does.** *(fixed in 275d674)*
|
||||
Where: `KeywordSettings.tsx:48,122`.
|
||||
- [x] **U-121 · P2 · PP · The vacation subject input is a fixed 240 dp.** *(fixed in f22e851)*
|
||||
- It squeezes its label into five lines.
|
||||
- Where: `VacationSettings.tsx:434`; the same pattern in `AccountSecuritySettings.tsx:234`.
|
||||
- [x] **U-122 · P2 · light · The amber banner in filters and the sieve editor is unreadable on
|
||||
light.** *(fixed in 8f68d24)*
|
||||
- Light yellow text on a faint brown fill.
|
||||
- Where: `FilterSettings.tsx:515-519`, `SieveEditorSheet.tsx:208-212`.
|
||||
- [x] **U-123 · P2 · TL TP · The section list and the page replace each other.** *(fixed in 8ab82cd)* Fix: phase 2.
|
||||
- [ ] **U-124 · P3 · all · The language list is in neither name nor code order.** *(deferred: the order copies the webmail language switcher on purpose (comment in src/i18n/index.ts); change both together)* Example: Česky,
|
||||
Slovenčina, Dansk.
|
||||
|
||||
### Login
|
||||
|
||||
- [x] **U-130 · P2 · TL TP · Login steps stretch to the full width.** *(fixed in 250c0a9)*
|
||||
- Fields and buttons are 1248 dp wide (`t_login_confirm`).
|
||||
- Fix: a centred column of about 480 dp.
|
||||
- [x] **U-131 · P2 · all · Small targets: show-password 20 dp, header back 32 dp, email suggestion
|
||||
chips about 26 dp.** *(fixed in ae1ef5b,c8434b0,776447d)* *(partly fixed in ae1ef5b (show password), c8434b0 (header); email suggestion chips still small)*
|
||||
- [x] **U-132 · P2 · all · The password step's return keys don't move on.** *(fixed in ae1ef5b)*
|
||||
- Username has no `next`. With TOTP on, the password field shows Next and does nothing.
|
||||
- Where: `PasswordStep.tsx:56,78`.
|
||||
- [ ] **U-133 · P2 · all · There is no way out while "Signing in" hangs.** *(deferred: a cancel needs an abortable sign-in in the auth flow, which is more than a UI fix; see product questions)* Hardware back is
|
||||
swallowed. Where: `SigningInStep.tsx`, `LoginScreen.tsx:165`.
|
||||
|
||||
### Found in the re-walk
|
||||
|
||||
- [x] **U-134 · P3 · all · The all-day label in the day and week grid reads "All".** A one-row
|
||||
strip cut the second line of "All day" ("Ganztägig" in German). *(fixed in e452f72)*
|
||||
- [x] **U-135 · P2 · PP, split · The selection header pushed Delete off the screen.** Eight 44 dp
|
||||
buttons with 12 dp gaps don't fit 412 dp; the count also kept its screen-reader label on the
|
||||
normal header afterwards. *(fixed in 0d96d8d)*
|
||||
- [ ] **U-136 · P3 · all · The day view keeps an empty all-day strip on days without all-day
|
||||
events.** The strip takes the height of the busiest day in the loaded range so the grid doesn't
|
||||
jump while paging (comment in `TimeGridScrollView.tsx`). *(deferred: a product call between a
|
||||
steady grid and less blank space; see product questions)*
|
||||
|
||||
## Adaptive layout plan
|
||||
|
||||
**Breakpoints** come from the window width (`useWindowDimensions`), not the device type, so
|
||||
split-screen, freeform windows and foldables work too:
|
||||
|
||||
| Class | Width | Typical |
|
||||
|---|---|---|
|
||||
| compact | < 600 dp | phones in portrait, narrow split-screen |
|
||||
| medium | 600–839 dp | tablet portrait, unfolded foldables |
|
||||
| expanded | ≥ 840 dp | tablet landscape |
|
||||
|
||||
A second flag, `shortHeight` (window height < 480 dp), covers phone landscape (914x411 dp). By
|
||||
width alone that would be "expanded", but two panes and a rail in 411 dp of height are worse than
|
||||
one pane. So a short window keeps the compact layouts and only compresses chrome: icon-only bars,
|
||||
a merged header row, no tab labels.
|
||||
|
||||
A small hook, `src/lib/use-layout.ts`, returns `{ widthClass, shortHeight, isSplit }` and a pure
|
||||
`layoutFor(width, height)` that can be tested.
|
||||
|
||||
**Navigation.** The phone stack stays as it is. A split view is a layout inside a tab, not a second
|
||||
navigator:
|
||||
|
||||
- Medium and expanded put the tab bar on the left as a rail (`tabBarPosition: 'left'`, material
|
||||
variant), like the webmail's navigation rail.
|
||||
- Stack routes still exist and are still used on compact widths. On medium and expanded, the list
|
||||
screens render the detail in a right-hand pane instead of pushing, and the detail screens' bodies
|
||||
are shared components that take props instead of route params.
|
||||
- If the window narrows while a pane shows something, the pane's selection is pushed as the normal
|
||||
stack route, so nothing is lost.
|
||||
|
||||
**Per area:**
|
||||
|
||||
| Area | compact | medium | expanded |
|
||||
|---|---|---|---|
|
||||
| Mail | list → push thread | list (360 dp) + thread pane | persistent folder sidebar (280 dp) + list + thread |
|
||||
| Calendar | as today | wider grid, 3-day shows 5 days | week shows 7 wide columns, 3-day shows 7, day shows 2 |
|
||||
| Contacts | list → push detail | list + detail pane | list + detail pane |
|
||||
| Files | list / grid | grid columns from width (about 140 dp each) | grid columns from width + preview pane when a file is selected |
|
||||
| Settings | list ↔ page | section list (320 dp) + page | section list + page |
|
||||
| Compose | full screen | centred panel, max 720 dp, dimmed backdrop | same |
|
||||
| Sheets, dialogs, toasts | full width at the bottom | max 560 dp, centred | max 560 dp, centred |
|
||||
| Login | as today | centred column, max 480 dp | same |
|
||||
|
||||
The thread, contact detail and settings pages cap their reading width (about 760 dp for the
|
||||
mail body, 640 dp for forms) inside wide panes.
|
||||
|
||||
**State across rotation.** The activity keeps its state through `configChanges` (orientation,
|
||||
screen size, smallest screen size, layout). Panes keep their selection in component state or the
|
||||
store. Lists that depend on width remount with their focused index or date carried over.
|
||||
|
||||
## Final report (2026-10-15)
|
||||
|
||||
### Result
|
||||
|
||||
86 findings (U-001 to U-136; numbers were skipped where a candidate turned out to be a duplicate).
|
||||
|
||||
| Severity | Fixed | No change needed | Deferred |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | 9 | 0 | 0 |
|
||||
| P2 | 57 | 1 | 2 |
|
||||
| P3 | 7 | 4 | 6 |
|
||||
|
||||
Every logic change has a vitest test that fails without it. The full suite (3273 tests), `tsc`
|
||||
and `npm run i18n:check` pass at the end.
|
||||
|
||||
### Areas and cells after the last walk
|
||||
|
||||
PP = phone portrait, PL = phone landscape, TP = tablet portrait, TL = tablet landscape. The last
|
||||
walk ran the phone in German and dark, the tablet in English and light. Font scale 1.3 was checked
|
||||
on PL and TL after a restart.
|
||||
|
||||
| Area | PP | PL | TP | TL |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Mail list, drawer, selection | ok | ok (search in header) | ok (list + message) | ok (folders + list + message) |
|
||||
| Thread | ok | ok (icon-only toolbar) | ok (docked) | ok (docked) |
|
||||
| Compose | ok | ok (format bar hidden while typing) | ok (panel) | ok (panel) |
|
||||
| Calendar month / week / day | ok | ok | ok | ok |
|
||||
| Contacts list, detail, form | ok | ok | ok (split) | ok (split) |
|
||||
| Files and preview | ok | ok | ok (docked preview) | ok (docked preview) |
|
||||
| Settings | ok | ok | ok (split) | ok (split) |
|
||||
| Login | ok | ok | ok (centred column) | ok (centred column) |
|
||||
|
||||
### Deferred
|
||||
|
||||
- U-016 scrim token: all scrims are already the same; a refactor with no visible change.
|
||||
- U-017 system alert vs Dialog: a style mismatch only; see the questions below.
|
||||
- U-018 font size change while the app is open: handling `fontScale` in the app was tried and left
|
||||
mixed old and new text sizes, worse than Android's recreate.
|
||||
- U-045 "Zurück"/"Weiter": the strings come from the webmail's German catalog.
|
||||
- U-069 recipient suggestions in phone landscape: no room above the keyboard; needs a new picker.
|
||||
- U-124 language order: copies the webmail switcher on purpose.
|
||||
- U-133 no way out of a hung sign-in: needs an abortable sign-in in the auth flow.
|
||||
- U-136 empty all-day strip in the day view: a product call; see below.
|
||||
|
||||
### Product questions
|
||||
|
||||
1. Dark-mode links stay blue (`#60a5fa`) while the rest follows the webmail's neutral palette.
|
||||
Recommendation: keep blue; links in mail bodies need to stand out.
|
||||
2. Should every confirmation use the app's Dialog instead of the system alert (U-017)?
|
||||
Recommendation: yes, for destructive actions first, as one pass of its own.
|
||||
3. Sign-in that hangs (U-133): add a Cancel that aborts discovery and the token request.
|
||||
Recommendation: yes; it is the only screen with no way back.
|
||||
4. Day view all-day strip (U-136): keep one height across days, or size it to the day on screen?
|
||||
Recommendation: size it to the visible days once paging settles.
|
||||
5. Month view on a phone now shows titles instead of start times even with "show time" on, since
|
||||
the time filled the whole chip. Recommendation: keep; times come back where cells are wide.
|
||||
6. Files keeps a full-screen preview on phones and docks it on tablets. Recommendation: keep.
|
||||
7. The dark background of plain-text mail is fixed at `#0a0a0a` and ignores the built-in themes.
|
||||
Recommendation: take it from the theme palette.
|
||||
8. APP.md still lists the dark palette as open; the app now uses the webmail standard theme. The
|
||||
note should be updated in the branding repo.
|
||||
|
||||
### Not tested
|
||||
|
||||
- iOS and iPad: nothing was run. The risk is in the `supportedOrientations` change on every Modal,
|
||||
the keyboard tracker (built around Android's missing frame after rotation) and the safe-area
|
||||
insets in landscape.
|
||||
- Real phones and tablets, foldables (fold and unfold mid-task), split-screen on real hardware.
|
||||
- Push notifications and their deep links.
|
||||
- Home-screen widgets: they truncate German labels; not changed here.
|
||||
- The test account came from a login created for this audit ("Stalwart test – uxaudit@example.org"
|
||||
in the Agent collection), because the "Stalwart test servers" item was not visible to the agent.
|
||||
@@ -0,0 +1,314 @@
|
||||
# Android widgets: verification checklist
|
||||
|
||||
Every tap target of the 32 Android home-screen widgets (`src/widgets/`), how
|
||||
it was tested on the emulator, and what happened. Started for the report
|
||||
"widget actions sometimes don't work" (#52 follow-up).
|
||||
|
||||
## Rig
|
||||
|
||||
- Emulator `Bulwark_Pixel7_API35` (Pixel 7, Android 15, x86_64), Pixel
|
||||
launcher, release APK built with `-PreactNativeArchitectures=x86_64`.
|
||||
- Servers: the standing test servers, Stalwart 0.16 (`stalwart-test`) and
|
||||
1.0 (`stalwart-test-v1`), reached through a logging reverse proxy that
|
||||
records every JMAP method, `notUpdated`/`notCreated` and errors, and can
|
||||
delay or fail chosen methods on request.
|
||||
- Accounts: `usera@example.org` (password) and `userb@example.org` (OAuth
|
||||
through Stalwart's own authorization server) signed in at once. Both are
|
||||
members of the `team@` group; `usera`'s calendar is shared with `userb`.
|
||||
Extra test data: tasks for both, invitations each way through server-side
|
||||
scheduling.
|
||||
- Placed: Triage, Tasks, Invitations (others in phase 3).
|
||||
|
||||
## States
|
||||
|
||||
| Code | State | How |
|
||||
|---|---|---|
|
||||
| FG | App on screen when the link arrives | `am start` of the link while the app is in front |
|
||||
| BG | App backgrounded, JS runtime alive | Home after using the app |
|
||||
| COLD | Process dead | `adb root`, `kill -9 <pid>` (not `am kill`, not `force-stop`) |
|
||||
| OFF | Offline | `cmd connectivity airplane-mode enable` |
|
||||
| ACC2 | Item belongs to the non-active account | the other account active in the app |
|
||||
| SHARED | Item in a shared or group account | `usera`'s calendar seen by `userb` |
|
||||
| DBL | Two taps in quick succession | `input tap` twice, 150 ms and 1–1.5 s apart |
|
||||
| UPD | Tap while a refresh is running | proxy delays `Email/set` by 4 s, second tap meanwhile |
|
||||
|
||||
A widget tap cannot happen with the app in front, so FG applies to deep
|
||||
links only. Links were opened with `am start -a VIEW -f 0x10000000`, which is
|
||||
exactly what the widget provider does (`RNWidgetProvider.openUri`); the
|
||||
widget's own Reply, card and event taps were also tapped on the launcher.
|
||||
|
||||
## Phase 1 results (before fixes, Stalwart 0.16)
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | Widgets | BG | COLD | OFF | ACC2 | DBL | UPD | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | see A1, A2, A3 |
|
||||
| trash | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | same code path as archive |
|
||||
| triageNext | Triage | pass | pass | pass | n/a | pass | pass | local only |
|
||||
| rsvp | Invitations | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5 |
|
||||
| toggleTask | Tasks, Today overview | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5, A6 |
|
||||
| markRead | (no button uses it) | – | – | – | – | – | – | kept for the API |
|
||||
| any button | all | pass* | pass* | – | – | – | – | *on this emulator; A0 |
|
||||
|
||||
### Deep links (`links.*` in `src/widgets/clicks.ts`)
|
||||
|
||||
| Link | Used by | FG | BG | COLD | OFF | ACC2 | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| inbox | Inbox, Unread count, Folder counts, Tag, Attachments, Mail and next, Today overview | pass | pass | pass | pass | n/a | |
|
||||
| message | Inbox/Starred/All accounts rows, Latest message, Triage card, Mail and next, Attachments | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| reply | Triage | **fail** | **fail** | **fail** | **fail** | **fail** | L1 |
|
||||
| draft | Last draft | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| unified (all / starred / role) | All accounts, Starred, Folder counts, Last draft | pass | pass | pass | not run | n/a | |
|
||||
| scheduled | Outbox, Folder counts | pass | pass | pass | not run | n/a | |
|
||||
| search (empty / query) | Inbox, Search mail, Favourite people | pass | pass | pass | not run | n/a | empty search opens the list without focusing the field |
|
||||
| compose (plain) | Inbox, Last draft | pass | pass | pass | pass | n/a | |
|
||||
| compose with to + subject | Birthdays "Send wishes", Up next "Running late" | **fail** | **fail** | **fail** | **fail** | n/a | L2 |
|
||||
| calendar | all calendar and plans widgets | pass | pass | pass | **crash** | n/a | L3 |
|
||||
| event | Week, Month, Today, Next event, Up next, Countdown, Invitations, Mail and next | **fail** | pass | pass | **crash** | **fail** | L3, L5 |
|
||||
| contacts | Shortcuts, Favourite people, Birthdays | pass | pass | pass | not run | n/a | |
|
||||
| files | Shortcuts, Recent files | pass | pass | pass | not run | n/a | |
|
||||
| settings/vacation, /account, /calendar | Vacation, Storage, Free time, Tasks (off) | pass | pass | pass | not run | n/a | |
|
||||
| video URL | Up next "Join" | – | – | – | – | – | external https link, opens the browser |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result | Notes |
|
||||
|---|---|---|
|
||||
| App goes to the background | **fail** (sometimes) | R1 |
|
||||
| Account switched in the app | **fail** | R1: widgets kept the previous account until the next app start |
|
||||
| Cold periodic update | pass for mail | R2: calendar, tasks, files, scheduled stay as last loaded by the app |
|
||||
| Back online after an offline tap | **fail** | A1: nothing brings the change back until the app or a 30-minute update runs |
|
||||
|
||||
## Causes found
|
||||
|
||||
- **A0. Buttons silently dead on fast phones.** The widget library registers
|
||||
every button's `PendingIntent` with `requestCode = (int)
|
||||
System.currentTimeMillis()` and `FLAG_CANCEL_CURRENT`. All buttons of one
|
||||
widget share the same action and component, so two buttons registered in
|
||||
the same millisecond get the same identity and the second registration
|
||||
cancels the first: that button then does nothing at all. On the emulator
|
||||
each registration takes 3–10 ms (measured from the request codes in
|
||||
`dumpsys activity intents`), so every button survived; a current phone
|
||||
makes the binder call in well under a millisecond, which makes the dead
|
||||
button depend on timing — "sometimes". The cancelled-then-recreated
|
||||
records also pile up (227 for three widgets after an hour of testing).
|
||||
- **A1. Offline or failed calls look like they worked.** The action edits
|
||||
the stored snapshot first and relies on the next refresh to undo a failed
|
||||
call. Offline that refresh fails too and returns the edited snapshot, so
|
||||
an archive that never reached the server shows as done; a cold refresh
|
||||
never reloads calendar or tasks, so a refused RSVP or task change is never
|
||||
undone either. Failures are only `console.warn`ed.
|
||||
- **A2. A fast second tap repeats the first.** Two taps 150 ms apart both
|
||||
archived `d2aaaaa3`: the second tap hits the buttons of the old drawing,
|
||||
which still name the first message. Harmless for archive; for task ticks
|
||||
the code would flip the task back, because `toggleTask` inverts whatever
|
||||
the stored snapshot says instead of carrying the state the user saw.
|
||||
- **A3. A refresh brings back a message just archived.** Each tap is its own
|
||||
headless task in the same runtime; they interleave at every await. With
|
||||
`Email/set` delayed 4 s, tap 1's refresh read the server before tap 2's
|
||||
archive landed, was stored anyway (it started after tap 2's local edit, so
|
||||
`noteLocalChange` did not catch it) and redrew the archived message
|
||||
(`baaaaaai`, log 08:44:19), until tap 2's own refresh removed it again.
|
||||
Separately, load–edit–save of the snapshot is not serialized, so two
|
||||
concurrent edits can drop one, and redraws of different snapshots can land
|
||||
out of order. In one run the second tap archived a message the card was
|
||||
not showing.
|
||||
- **A4. RSVP and task ticks go to the wrong account after a switch.** They
|
||||
pick the client from `snapshot.activeAccountId` and look the item up by id
|
||||
in the current snapshot, not from the tap's own data. JMAP ids are small
|
||||
per-account sequences (`e`, `f`, `h1kbleaaaaaj`), so a tap on a drawing
|
||||
made before an account switch can match another account's task or event
|
||||
with the same id. Not reproduced on the device (the window between the
|
||||
stored refresh and the redraw is short); follows from the code.
|
||||
- **A5. Nothing tells the user.** See A1.
|
||||
- **A6. The task tick target is 22 dp.** A tap on the task's title or a few
|
||||
dp beside the circle does nothing.
|
||||
- **Lead 2 (`target` lacks `widgetId`) is ruled out**: the logged click data
|
||||
of Archive/Delete is `{"id":…,"widgetId":3,"accountId":…}`.
|
||||
- **Lead 4 (credentials in a cold runtime)**: the password account restores
|
||||
and acts from a cold start (archive, task tick). The OAuth account with an
|
||||
access token that expired while the app was dead is checked in phase 2.
|
||||
- **Lead 5 (`progressUpdated`)** is ruled out: the widget's task update has
|
||||
carried no `progressUpdated` since the B12 fix, and both 0.16 and 1.0
|
||||
accept it (`updated`, no `notUpdated`).
|
||||
- **R1. The refresh on going to the background often never runs.** With an
|
||||
account switched in the app and Home pressed, no refresh request reached
|
||||
the proxy and the widgets kept the old account's mail.
|
||||
- **R2. Cold refreshes skip calendar and tasks.** Only mail has JMAP code of
|
||||
its own; the rest needs the app's stores, which only run with the UI.
|
||||
- **L1. Widget Reply never opens the composer.** The reader opens on the
|
||||
message but the composer does not follow, in every state.
|
||||
- **L2. "Send wishes" loses its subject.** `parseDeepLink` takes the
|
||||
subject from the mailto parser, which returns an empty string rather than
|
||||
nothing, so the link's own `subject` is never used.
|
||||
- **L3. Calendar links crash the app offline.** On an offline cold start the
|
||||
Calendar tab mounts `CalendarShareSheet`, whose list of people to share
|
||||
with calls `ownPrincipalId()` while rendering; with no session that throws
|
||||
"Not authenticated - call connect() first" and the app closes.
|
||||
- **L4. Message links for the other account fail offline.** The link
|
||||
switches accounts first, which needs the server; offline the switch fails
|
||||
and the app shows "Couldn't open this".
|
||||
- **L5. Event links carry no account.** With the other account active, an
|
||||
event link opens the Calendar tab and nothing else (or, with an id that
|
||||
exists in both accounts, the wrong event). In the foreground the event
|
||||
link landed on the Inbox.
|
||||
|
||||
## Corrections to phase 1
|
||||
|
||||
- **L1 and L2 were faults of the test rig, not of the app.** `adb shell am
|
||||
start -d <url>` handed the URL to the device shell unquoted, and the shell
|
||||
cut it at the first `&`: Reply lost `&action=reply` (and `&thread=`), and
|
||||
"Send wishes" lost `&subject=`. Quoted, Reply opens the composer over the
|
||||
message and the subject arrives, cold, backgrounded and in the foreground,
|
||||
on 0.16 and 1.0.
|
||||
- **L4 is two app limits, not a widget fault.** A message link offline needs
|
||||
the message on the device: with the offline mail cache off (the default)
|
||||
a message is not kept across app starts and the reader says "Failed to
|
||||
load email". A link for the other account needs the server for the account
|
||||
switch, so offline it says "Couldn't open this".
|
||||
- **R1 had a second half.** Starting the background refresh directly (no
|
||||
timer) was not enough: fetch resolves every response through a zero-delay
|
||||
timer, and with the activity paused the first request went out and its
|
||||
answer was never read. The refresh now runs in a headless task of its own.
|
||||
|
||||
## After the fixes (phase 2)
|
||||
|
||||
Same rig. 1.0 was reached as `http://127.0.0.1:18191` through `adb
|
||||
reverse`, so its accounts (`usera@127.0.0.1`) sit next to the 0.16 ones;
|
||||
that path does not go through the emulator's network, so "offline" on 1.0
|
||||
was simulated with the proxy refusing `Email/set` (503) instead of airplane
|
||||
mode.
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | BG | COLD | OFF | ACC2 | DBL | UPD | 1.0 | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | pass | pass | pass | n/a | pass | pass | pass | offline: the message comes back on the card with "Couldn't archive. Tap to try again."; the tap archives it once online |
|
||||
| trash | pass | pass | pass | n/a | pass | pass | pass | offline: "Couldn't delete. Tap to try again."; the tap moves it to Trash once online |
|
||||
| triageNext | pass | pass | pass | n/a | pass | pass | pass | local only, no server call |
|
||||
| rsvp | pass | pass | pass | pass (unit test) | not run | not run | call passes, see 1.0 below | offline: the invitation stays with "Couldn't send your answer"; the retry accepts it. DBL and UPD not run on the device: they go through the same serial lane and pending overlay as archive and the tick, and `actions.test.ts` covers both |
|
||||
| toggleTask | pass | pass | pass | pass (unit test) | pass | not run | call passes, see 1.0 below | two taps 150 ms apart send `completed` twice; offline the tick reverts with a notice naming the task, and tapping the notice once online sends one `CalendarEvent/set` and the task shows done. UPD not run on the device (see rsvp) |
|
||||
| any button | pass | pass | – | – | – | – | – | request codes unique (107 click intents, 107 codes) |
|
||||
|
||||
DBL for archive: two taps 150 ms apart still both name the message the
|
||||
drawing showed (the second is a harmless repeat); taps a second apart act on
|
||||
consecutive messages. UPD: with `Email/set` delayed 4 s, two archives 1.5 s
|
||||
apart leave the card on "5 of 5" for the whole run and nothing comes back
|
||||
(before: the first archive's refresh brought the second message back). Trash
|
||||
behaves the same: a double tap names the shown message twice, and two
|
||||
deletes 1.5 s apart with `Email/set` delayed move two different messages
|
||||
and nothing comes back.
|
||||
|
||||
### Deep links
|
||||
|
||||
| Link | FG | BG | COLD | OFF | ACC2 | 1.0 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| inbox, unified, scheduled, search, compose | pass | pass | pass | pass | n/a | pass |
|
||||
| message | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| reply | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| draft | pass | pass | pass | only if opened before (L4) | pass | not run (same route as message) |
|
||||
| compose with to + subject | pass | pass | pass | not run (no server involved) | n/a | pass |
|
||||
| calendar | pass | pass | pass | pass (no crash) | n/a | opens; see 1.0 below |
|
||||
| event | pass | pass | pass | opens the Calendar tab; the event needs the server | pass (switches account) | see 1.0 below |
|
||||
| contacts, settings | pass | pass | pass | pass (Vacation settings say the server does not support it while offline) | n/a | pass |
|
||||
| files | pass | pass | pass | pass (no crash; the screen shows the raw network error) | n/a | pass |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result |
|
||||
|---|---|
|
||||
| Account switched in the app, then Home | pass: the widgets show the new account within a second, both directions |
|
||||
| App goes to the background | pass |
|
||||
| Cold periodic update | pass: mail, calendar, tasks, files and scheduled (`appDataAt` set) |
|
||||
| OAuth account with an expired access token, cold | pass: userb's token had expired an hour after sign-in; the cold refresh renewed it and loaded userb's inbox preview |
|
||||
|
||||
### Stalwart 1.0
|
||||
|
||||
- Mail actions and links pass: the archive race, a refused write with its
|
||||
notice and retry, trash, triage next, message and reply links cold and
|
||||
backgrounded, and the inbox, unified, scheduled, search, compose,
|
||||
calendar, contacts, files and settings links.
|
||||
- The app on main does not load calendar events or tasks from 1.0: its
|
||||
`CalendarEvent/get` asks for `recurrenceOverrides` together with
|
||||
`utcStart`/`utcEnd`, which 1.0 refuses as a whole (`invalidArguments`).
|
||||
The unmerged `feat/stalwart-1.0` branch fixes that. Until it is merged the
|
||||
calendar and task widgets are empty against 1.0. The widgets' own calls
|
||||
were sent to 1.0 directly: the task update (`progress`,
|
||||
`percentComplete`) and the RSVP patch both come back `updated`.
|
||||
|
||||
### Causes and their fixes
|
||||
|
||||
| Cause | Commit |
|
||||
|---|---|
|
||||
| A0 dead buttons (shared request codes) | `fix: stop widget buttons from going dead when two are drawn in the same millisecond` |
|
||||
| A3 refresh brings an archived message back, lost concurrent edits, out-of-order redraws | `fix: keep a widget archive when a second tap's refresh lands before it` |
|
||||
| A1, A5 failures looked like success, no feedback | the commit above (a failed op is dropped) and `feat: show on the widget when an action could not reach the server` |
|
||||
| A2, A4 tick flips back, item of another account | `fix: send widget RSVPs and task ticks to the item the tap was made on` |
|
||||
| A6 22dp tick | `fix: make widget task ticks easy to hit` |
|
||||
| R1 no refresh on leaving the app | `fix: refresh the widgets when the app goes to the background`, `fix: finish the widget refresh after leaving the app` |
|
||||
| R2 cold refresh skipped calendar and tasks | `fix: load calendar and tasks into the widgets when the app is not running` |
|
||||
| L3 offline crash from calendar and files widgets | `fix: keep the app from closing when a calendar widget opens it offline`, `fix: keep the app from closing when a files widget opens it offline` |
|
||||
| L5 event links without account | `fix: open a widget's event in the account it belongs to` |
|
||||
|
||||
## Phase 3: layout review
|
||||
|
||||
All 32 widgets were drawn through a temporary in-app gallery
|
||||
(`WidgetPreview`, removed again) at their smallest, default and largest
|
||||
(4x5) size with sample data, light and dark, German, very long names and
|
||||
counts (five-digit counts, 50-character names and subjects), empty data,
|
||||
and the loading, signed-out, not-yet-loaded and unsupported states, and
|
||||
with the tap areas highlighted. Fixed:
|
||||
|
||||
- The Inbox header at two cells pushed the compose button off; header
|
||||
titles now give way to the buttons, and the search icon goes at that
|
||||
width.
|
||||
- A long sender pushed the triage card's time and position out.
|
||||
- German labels were cut on the triage buttons ("Archivie", "Lösc"); they
|
||||
drop to icons when they do not fit.
|
||||
- Five-digit counts overflowed Unread count, Tag and Mail and next, and the
|
||||
total wrapped a character per line; counts from 10,000 are shortened.
|
||||
- "ungelesen" was cut in Mail and next; it moves under the number.
|
||||
- Buttons under 48dp (32–36dp) get a transparent 48dp tap area.
|
||||
- The Month arrows and the Vacation switch looked like controls but only
|
||||
opened the app.
|
||||
- Folder counts with no folders showed a bare heading.
|
||||
- A finished task past its due date read "Overdue".
|
||||
- The triage card and Latest message did not use their height for the
|
||||
preview.
|
||||
- A failure notice about a long task title cut off "Tap to try again".
|
||||
- Widget strings were English in every language; German added.
|
||||
- Avatars showed initials only, worked out differently from the webmail.
|
||||
Initials now follow the webmail everywhere in the app (first letters of
|
||||
the first and last name, leading punctuation and emoji skipped), and the
|
||||
widget avatars show the contact photo or the sender's logo first, as the
|
||||
app's list does. Logos come from the same favicon service, are fetched
|
||||
when the snapshot is built and are cached for offline drawing; a logo
|
||||
over 40 KB, or one Android cannot decode, leaves the initials.
|
||||
- The corners were rounder (22dp) than the launcher's own widgets; they are
|
||||
16dp now, previews included.
|
||||
|
||||
Checked and left as they are:
|
||||
|
||||
- Dark theme: no white fallbacks; the primary is white as in the webmail.
|
||||
- Large sizes of the 2x2 widgets are mostly empty space. A resize limit
|
||||
(`maxResizeWidth`) is given in dp, and on launchers with large cells it
|
||||
would pin a widget below two cells, so there is none.
|
||||
- RTL is not supported: the widget library does not mirror layouts, and
|
||||
there are no right-to-left widget strings.
|
||||
- Mail and next's chip for the following event is 24dp tall; the whole
|
||||
column also opens the calendar.
|
||||
|
||||
## Open
|
||||
|
||||
- A0 cannot be shown on this emulator, where each registration takes
|
||||
3–10 ms; the fix is checked by the request codes being unique.
|
||||
- Calendar and task widgets against Stalwart 1.0 wait for
|
||||
`feat/stalwart-1.0` (see above).
|
||||
- Offline message links need the offline cache; links for the other account
|
||||
need the server for the switch (L4).
|
||||
- Widget strings exist in English and German only.
|
||||
- RSVP double tap and update race, and the tick's update race, were not run
|
||||
on the device; unit tests cover them.
|
||||
- The Files screen opened offline shows the raw network error instead of an
|
||||
offline message (app screen, not a widget).
|
||||
@@ -6,6 +6,7 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
||||
import { markUiStarted } from './src/widgets/ui-presence';
|
||||
|
||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||
// from BulwarkMessagingService on an FCM data message. Must be registered
|
||||
@@ -15,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
|
||||
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
|
||||
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
|
||||
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
|
||||
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Device sync (Android, #34): the contacts and calendar sync adapters run the
|
||||
// sync engine through this task, headless or next to the UI. The engine is
|
||||
// required on first use so an app start does not load it.
|
||||
@@ -25,11 +32,14 @@ AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import(
|
||||
|
||||
// Home-screen widgets (Android): every widget event runs this headless task.
|
||||
registerWidgetTaskHandler(widgetTaskHandler);
|
||||
// ...and leaving the app refreshes them in one (BulwarkWidgetRefreshService).
|
||||
AppRegistry.registerHeadlessTask('BulwarkWidgetRefresh', () => refreshWidgetsInBackground);
|
||||
|
||||
// A headless push task evaluates this file too, so App is required when the
|
||||
// UI first renders instead of imported above: importing it here would load
|
||||
// every screen, store and icon before the task could run.
|
||||
function Root() {
|
||||
markUiStarted();
|
||||
const App = (require('./App') as typeof import('./App')).default;
|
||||
return createElement(App);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "رابط",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
|
||||
"tap_to_create": "اضغط + لإنشاء حدث"
|
||||
},
|
||||
"fab": {
|
||||
"event": "حدث",
|
||||
"task": "مهمة"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "ملاحظات، جدول أعمال…",
|
||||
"location_placeholder": "المكان أو العنوان",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "لم تعد هذه المهمة متاحة.",
|
||||
"save_error": "فشل حفظ المهمة",
|
||||
"update_error": "فشل تحديث المهمة"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "الجدول",
|
||||
"three_days": "3 أيام"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enllaç",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Res planificat. Toca per crear.",
|
||||
"tap_to_create": "Toqueu + per crear-ne un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esdeveniment",
|
||||
"task": "Tasca"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre del dia…",
|
||||
"location_placeholder": "Lloc o adreça",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Aquesta tasca ja no està disponible.",
|
||||
"save_error": "No s'ha pogut desar la tasca",
|
||||
"update_error": "No s'ha pogut actualitzar la tasca"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 dies"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
|
||||
"severity_deprecated": "Obligatòria",
|
||||
"severity_security": "Seguretat",
|
||||
"title": "Actualitzacions de l'aplicació"
|
||||
"title": "Actualitzacions de l'aplicació",
|
||||
"verifying": "S'està verificant…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.",
|
||||
"tap_to_create": "Událost vytvoříte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Událost",
|
||||
"task": "Úkol"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Místo nebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Tento úkol už není k dispozici.",
|
||||
"save_error": "Úkol se nepodařilo uložit",
|
||||
"update_error": "Úkol se nepodařilo aktualizovat"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dny"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečení",
|
||||
"title": "Aktualizace aplikace"
|
||||
"title": "Aktualizace aplikace",
|
||||
"verifying": "Ověřování…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Intet planlagt. Tryk for at oprette.",
|
||||
"tap_to_create": "Tryk på + for at oprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Begivenhed",
|
||||
"task": "Opgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Noter, dagsorden…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne opgave er ikke længere tilgængelig.",
|
||||
"save_error": "Kunne ikke gemme opgaven",
|
||||
"update_error": "Kunne ikke opdatere opgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Tidsplan",
|
||||
"three_days": "3 dage"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
|
||||
"severity_deprecated": "Påkrævet",
|
||||
"severity_security": "Sikkerhed",
|
||||
"title": "Appopdateringer"
|
||||
"title": "Appopdateringer",
|
||||
"verifying": "Verificerer…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nichts geplant. Zum Erstellen tippen.",
|
||||
"tap_to_create": "Tippen Sie auf +, um einen zu erstellen"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Termin",
|
||||
"task": "Aufgabe"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notizen, Tagesordnung…",
|
||||
"location_placeholder": "Ort oder Adresse",
|
||||
@@ -123,10 +128,16 @@
|
||||
"add_placeholder": "Aufgabe hinzufügen…",
|
||||
"add_time": "Uhrzeit hinzufügen",
|
||||
"clear_due_date": "Fälligkeitsdatum entfernen",
|
||||
"delete_confirm": "Diese Aufgabe löschen?",
|
||||
"delete_error": "Aufgabe konnte nicht gelöscht werden",
|
||||
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
|
||||
"save_error": "Aufgabe konnte nicht gespeichert werden",
|
||||
"update_error": "Aufgabe konnte nicht aktualisiert werden"
|
||||
},
|
||||
"views": {
|
||||
"n_days": "{count} Tage",
|
||||
"schedule": "Terminübersicht",
|
||||
"three_days": "3 Tage"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -301,6 +312,10 @@
|
||||
"send_now": "Jetzt senden",
|
||||
"send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.",
|
||||
"send_timeout_title": "Keine Antwort vom Server",
|
||||
"toolbar": {
|
||||
"insert_image": "Bild einfügen",
|
||||
"remove_link": "Link entfernen"
|
||||
},
|
||||
"undo_send_label": "Senden in {seconds} s…",
|
||||
"upload_failed_short": "Upload fehlgeschlagen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
@@ -438,6 +453,7 @@
|
||||
"share_no_matches": "Keine Treffer",
|
||||
"share_no_users": "Keine anderen Benutzer gefunden",
|
||||
"share_remove": "Zugriff entfernen",
|
||||
"share_remove_confirm": "Zugriff entfernen?",
|
||||
"share_role_custom": "Benutzerdefiniert",
|
||||
"share_search_placeholder": "Nach Name oder E-Mail suchen",
|
||||
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
|
||||
@@ -632,12 +648,14 @@
|
||||
},
|
||||
"toolbar_labels": {
|
||||
"description_mobile": "Textbeschriftungen neben den Symbolleistensymbolen anzeigen."
|
||||
}
|
||||
},
|
||||
"description_mobile": "Passe an, wie die App aussieht"
|
||||
},
|
||||
"badges": {
|
||||
"experimental": "Experimentell",
|
||||
"not_implemented": "Nicht implementiert",
|
||||
"unavailable": "Nicht verfügbar"
|
||||
"unavailable": "Nicht verfügbar",
|
||||
"not_on_server": "Nicht auf diesem Server"
|
||||
},
|
||||
"contacts": {
|
||||
"book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
|
||||
@@ -857,6 +875,7 @@
|
||||
"text_signature": "Klartext-Signatur"
|
||||
},
|
||||
"keywords": {
|
||||
"delete_confirm_title": "Tag „{label}“ löschen?",
|
||||
"description_mobile": "Farbige Labels zum Organisieren Ihrer E-Mails.",
|
||||
"discover": {
|
||||
"add_all": "Alle hinzufügen",
|
||||
@@ -979,7 +998,15 @@
|
||||
"window": "Zeitraum",
|
||||
"window_24h": "Letzte 24 Stunden",
|
||||
"window_days": "Letzte {count} Tage",
|
||||
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht."
|
||||
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht.",
|
||||
"description_short": "Neue Nachrichten auf dem Telefon behalten, damit sie auch ohne Verbindung aufgehen.",
|
||||
"keep_24h": "24 Stunden",
|
||||
"keep_days": "{count} Tage",
|
||||
"keep_from": "Mails der letzten",
|
||||
"messages_offline": "{count, plural, one {# Nachricht offline} other {# Nachrichten offline}}",
|
||||
"synced": "Synchronisiert {when}",
|
||||
"use_at_most": "Höchstens belegen",
|
||||
"used_of": "{used} von {limit} MB"
|
||||
},
|
||||
"plugins": {
|
||||
"mobile_description": "Plugins laufen im Webmail und erweitern es im Browser.",
|
||||
@@ -1087,7 +1114,10 @@
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Designs",
|
||||
"updates": "Updates"
|
||||
"updates": "Updates",
|
||||
"storage": "Speicher & Sicherung",
|
||||
"about": "Über",
|
||||
"troubleshooting": "Fehlerbehebung"
|
||||
},
|
||||
"templates": {
|
||||
"delete_confirm": "\"{name}\" endgültig löschen?",
|
||||
@@ -1111,6 +1141,86 @@
|
||||
"warnings": {
|
||||
"date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"check_now": "Jetzt prüfen",
|
||||
"checked_at": "Geprüft {when}",
|
||||
"licences": "Open-Source-Lizenzen",
|
||||
"licences_desc": "AGPL-3.0 für die App und die Bibliotheken, die sie nutzt",
|
||||
"never_checked": "Noch nicht geprüft",
|
||||
"privacy": "Datenschutz",
|
||||
"privacy_desc": "Was auf diesem Telefon bleibt und was an deinen Server geht",
|
||||
"report_problem": "Problem melden",
|
||||
"report_problem_desc": "Öffnet ein Issue auf GitHub mit eingetragener Version",
|
||||
"server": "Server",
|
||||
"server_value": "{host} · angemeldet als {user}",
|
||||
"source_code": "Quellcode",
|
||||
"this_device": "Dieses Gerät",
|
||||
"this_device_desc": "Womit die App verbunden ist und wo du Hilfe bekommst.",
|
||||
"up_to_date": "Aktuell",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"backup": {
|
||||
"description": "Übertrage deine Einstellungen auf ein anderes Telefon oder ins Webmail.",
|
||||
"export_import": "Exportieren oder importieren",
|
||||
"export_import_desc": "Eine JSON-Datei im selben Format wie im Webmail",
|
||||
"reset": "Zurücksetzen",
|
||||
"reset_desc": "Zurück auf die Standardwerte; Mails bleiben",
|
||||
"title": "Einstellungen sichern"
|
||||
},
|
||||
"licences": {
|
||||
"app_desc": "Die App ist freie Software unter der {licence}. Du darfst sie nutzen, untersuchen, weitergeben und ändern.",
|
||||
"libraries": "Bibliotheken",
|
||||
"libraries_desc": "Open-Source-Code, mit dem die App gebaut ist.",
|
||||
"read_licence": "Den vollständigen Lizenztext lesen"
|
||||
},
|
||||
"privacy": {
|
||||
"favicons": "Absendersymbole für geschäftliche Mails kommen vom Symboldienst von DuckDuckGo, der dabei die Domain des Absenders sieht. Ausschalten kannst du sie unter Darstellung → Absender-Favicons.",
|
||||
"no_tracking": "Die App hat keine Analyse und keine Werbung.",
|
||||
"other_title": "Weitere Verbindungen",
|
||||
"phone_backup": "Nichts davon landet in der Cloud-Sicherung von Android.",
|
||||
"phone_secure": "Deine Anmeldedaten liegen im sicheren Speicher des Systems. Einstellungen, der Offline-Cache und noch nicht synchronisierte Änderungen liegen im Speicher der App.",
|
||||
"phone_title": "Auf diesem Telefon",
|
||||
"push": "Wenn Benachrichtigungen an sind, meldet dein Server dem Push-Relay ({relay}), dass sich etwas geändert hat, und das Relay weckt die App über {service}. Das Relay sieht ein Gerätetoken und eine Änderungsmarke, nie deine Mails. Unter Benachrichtigungen kannst du ein eigenes Relay eintragen.",
|
||||
"push_title": "Push-Benachrichtigungen",
|
||||
"server": "Mails, Kalender, Kontakte und Dateien werden von deinem Server geladen und dort gespeichert. Außer den Verbindungen unten spricht die App nur mit deinem Server.",
|
||||
"server_title": "Dein Mailserver",
|
||||
"service_apple": "den Push-Dienst von Apple",
|
||||
"service_google": "den Push-Dienst von Google",
|
||||
"updates": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen.",
|
||||
"updates_android": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen. Ausschalten kannst du das unter Updates."
|
||||
},
|
||||
"summary": {
|
||||
"events_off": "Termine aus",
|
||||
"events_on": "Termine an",
|
||||
"mail_off": "Mail aus",
|
||||
"mail_on": "Mail an",
|
||||
"offline_off": "Offline-Mails aus",
|
||||
"offline_on": "Offline-Mails an · {days, plural, one {# Tag} other {# Tage}}, {mb} MB",
|
||||
"version": "Version {version}",
|
||||
"week_starts": "Woche beginnt {day}",
|
||||
"layout": "Schnellaktionen, Wischen, Reihenfolge",
|
||||
"reading": "Gelesen-Status, Löschen, Anhänge",
|
||||
"composing": "Signatur, Sendeverzögerung, Entwürfe",
|
||||
"identities": "Adressen, von denen du sendest",
|
||||
"vacation": "Automatische Antwort bei Abwesenheit",
|
||||
"filters": "Regeln für eingehende Mails",
|
||||
"templates": "Wiederverwendbare Nachrichten",
|
||||
"folders": "Anlegen, umbenennen, Rollen",
|
||||
"keywords": "Namen und Farben der Tags",
|
||||
"downloads": "Dateinamen für gespeicherte Mails",
|
||||
"security": "Passwort, Zwei-Faktor, App-Passwörter",
|
||||
"content_senders": "Externe Bilder, vertrauenswürdige Absender",
|
||||
"contacts": "Adressbücher, Sortierung",
|
||||
"files": "Ansicht, Sortierung, Vorschaubilder",
|
||||
"sidebar_apps": "Web-Apps im Seitenmenü",
|
||||
"updates": "Neue Versionen installieren"
|
||||
},
|
||||
"troubleshooting": {
|
||||
"cache_desc": "Wenn eine Liste veraltet oder falsch aussieht.",
|
||||
"cache_title": "Zwischengespeicherte Daten",
|
||||
"row_desc": "Daten neu laden, Debug-Protokoll",
|
||||
"title": "Fehlerbehebung"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1119,6 +1229,11 @@
|
||||
"remove_account_message": "Von {account} auf diesem Gerät abmelden. Ihre E-Mails bleiben auf dem Server.",
|
||||
"remove_account_title": "Konto entfernen?",
|
||||
"show_unread": "Ungelesene anzeigen",
|
||||
"sign_out_all_confirm_message": "Alle Konten werden von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
|
||||
"sign_out_all_confirm_title": "Von allen Konten abmelden?",
|
||||
"sign_out_confirm_message": "Das Konto wird von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
|
||||
"sign_out_confirm_title": "Abmelden?",
|
||||
"sign_out_confirm_title_account": "Von {account} abmelden?",
|
||||
"tab_unavailable": "{name} (nicht verfügbar)"
|
||||
},
|
||||
"unified_mailbox": {
|
||||
@@ -1168,6 +1283,198 @@
|
||||
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
|
||||
"severity_deprecated": "Erforderlich",
|
||||
"severity_security": "Sicherheit",
|
||||
"title": "App-Updates"
|
||||
"title": "App-Updates",
|
||||
"verifying": "Wird überprüft…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
"empty": "Keine neuen Anhänge",
|
||||
"from_mail": "aus E-Mail",
|
||||
"title": "Anhänge",
|
||||
"unknown_sender": "Unbekannter Absender"
|
||||
},
|
||||
"birthdays": {
|
||||
"empty": "Keine Geburtstage in Sicht",
|
||||
"empty_body": "Geburtstage kommen aus Ihren Kontakten.",
|
||||
"in_days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"send_wishes": "Gratulieren",
|
||||
"title": "Geburtstage",
|
||||
"wishes_subject": "Alles Gute zum Geburtstag!"
|
||||
},
|
||||
"calendar": {
|
||||
"agenda": "Agenda",
|
||||
"agenda_more": "{day}: {events}",
|
||||
"all_day": "Ganztägig",
|
||||
"join": "Teilnehmen",
|
||||
"join_named": "{name} beitreten",
|
||||
"more_this_week": "{count, plural, one {# weiterer diese Woche} other {# weitere diese Woche}}",
|
||||
"no_more_today": "Heute keine Termine mehr",
|
||||
"nothing_planned": "Nichts im Kalender",
|
||||
"now": "Jetzt",
|
||||
"open": "Öffnen",
|
||||
"running_late": "Verspäte mich",
|
||||
"running_late_body": "Ich komme ein paar Minuten später zu {title}.",
|
||||
"running_late_subject": "Verspäte mich: {title}",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"unit_days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"unit_hours": "{count, plural, one {Std.} other {Std.}}",
|
||||
"unit_minutes": "{count, plural, one {Min.} other {Min.}}",
|
||||
"unsupported": "Kalender ist auf diesem Server nicht verfügbar",
|
||||
"untitled": "(ohne Titel)",
|
||||
"video_call": "Videoanruf"
|
||||
},
|
||||
"countdown": {
|
||||
"days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"empty": "Keine anstehenden Termine"
|
||||
},
|
||||
"draft": {
|
||||
"edited_at": "Bearbeitet {when}",
|
||||
"edited_minutes": "{count, plural, one {Vor # Min. bearbeitet} other {Vor # Min. bearbeitet}}",
|
||||
"edited_now": "Gerade bearbeitet",
|
||||
"empty": "Keine Entwürfe",
|
||||
"empty_body": "Nachrichten, die Sie beginnen und nicht senden, warten hier.",
|
||||
"keep_writing": "Weiterschreiben",
|
||||
"title": "Entwürfe"
|
||||
},
|
||||
"files": {
|
||||
"empty": "Noch keine Dateien",
|
||||
"recent": "Zuletzt geändert",
|
||||
"unsupported": "Dateien sind auf diesem Server nicht verfügbar"
|
||||
},
|
||||
"free": {
|
||||
"busy_until": "Belegt bis {time}",
|
||||
"free_rest": "Den Rest des Tages frei",
|
||||
"free_until": "Frei bis {time}",
|
||||
"mail": "Freie Zeiten mailen",
|
||||
"mail_subject": "Meine freien Zeiten diese Woche",
|
||||
"no_calendar": "Kein Kalender in diesem Konto",
|
||||
"then": "danach {title}",
|
||||
"then_free": "danach frei"
|
||||
},
|
||||
"hub": {
|
||||
"all_clear": "Alles erledigt",
|
||||
"mail_unread": "E-Mail · {count} ungelesen",
|
||||
"next": "Als Nächstes",
|
||||
"next_on": "Als Nächstes · {day}",
|
||||
"no_events": "Keine anstehenden Termine",
|
||||
"now": "Jetzt",
|
||||
"tasks_due": "Aufgaben · {count} fällig"
|
||||
},
|
||||
"invites": {
|
||||
"accept": "Zusagen",
|
||||
"decline": "Absagen",
|
||||
"empty": "Keine offenen Einladungen",
|
||||
"label": "Einladung · {name}",
|
||||
"label_plain": "Einladung",
|
||||
"maybe": "Vielleicht",
|
||||
"position": "{index} von {count}"
|
||||
},
|
||||
"mail": {
|
||||
"all_accounts": "Alle Konten",
|
||||
"all_caught_up": "Alles gelesen",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"empty_all_accounts": "Ihre Posteingänge sind leer",
|
||||
"empty_inbox": "Ihr Posteingang ist leer",
|
||||
"folders": "Ordner",
|
||||
"in_outbox": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}",
|
||||
"in_outbox_short": "{count} warten",
|
||||
"inbox": "Posteingang",
|
||||
"more_unread": "+{count} weitere ungelesen",
|
||||
"no_folders": "Keine Ordner",
|
||||
"no_starred": "Keine markierten E-Mails",
|
||||
"no_starred_body": "Markieren Sie eine Nachricht mit einem Stern, um sie hier zu behalten.",
|
||||
"no_subject": "(kein Betreff)",
|
||||
"no_unread": "Keine ungelesenen Nachrichten im Posteingang.",
|
||||
"position": "{index} von {count}",
|
||||
"reply": "Antworten",
|
||||
"scheduled": "Geplant",
|
||||
"starred": "Markiert",
|
||||
"unread": "ungelesen"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Archivieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"mark_read": "Als gelesen markieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"rsvp": "Antwort nicht gesendet. Tippen zum Wiederholen.",
|
||||
"task": "„{title}“ nicht aktualisiert. Tippen zum Wiederholen.",
|
||||
"trash": "Löschen fehlgeschlagen. Tippen zum Wiederholen."
|
||||
},
|
||||
"people": {
|
||||
"empty": "Noch keine Favoriten",
|
||||
"empty_body": "Die Personen, von denen Sie am meisten hören, erscheinen hier.",
|
||||
"favourites": "Favoriten"
|
||||
},
|
||||
"relative": {
|
||||
"days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"hours": "in {count} Std.",
|
||||
"minutes": "in {count} Min.",
|
||||
"now": "jetzt"
|
||||
},
|
||||
"scheduled": {
|
||||
"day_at": "{day}, {time}",
|
||||
"empty": "Nichts geplant",
|
||||
"empty_body": "Nachrichten, die Sie später senden lassen, erscheinen hier.",
|
||||
"no_recipient": "(kein Empfänger)",
|
||||
"sending": "Wird gesendet",
|
||||
"to": "An: {name}",
|
||||
"today_at": "Heute, {time}",
|
||||
"tomorrow_at": "Morgen, {time}",
|
||||
"waiting": "Wartet auf Netz",
|
||||
"waiting_body": "Offline vorgenommene Änderungen werden gesendet, sobald Sie wieder online sind."
|
||||
},
|
||||
"search": {
|
||||
"no_recent": "Ihre letzten Suchen erscheinen hier.",
|
||||
"placeholder": "E-Mails durchsuchen…",
|
||||
"recent": "Letzte Suchen"
|
||||
},
|
||||
"shortcuts": {
|
||||
"calendar": "Kalender",
|
||||
"compose": "Verfassen",
|
||||
"contacts": "Kontakte",
|
||||
"files": "Dateien"
|
||||
},
|
||||
"state": {
|
||||
"error": "Widget kann nicht angezeigt werden",
|
||||
"loading": "Wird geladen…",
|
||||
"open_app": "Öffnen Sie die App einmal, falls dies leer bleibt.",
|
||||
"open_to_load": "Öffnen Sie Bulwark, um dies zu laden",
|
||||
"signed_out": "Bei Bulwark anmelden"
|
||||
},
|
||||
"storage": {
|
||||
"no_limit": "Kein Speicherlimit",
|
||||
"title": "Speicher",
|
||||
"unavailable": "Keine Angaben zum Speicher",
|
||||
"used_of": "{used} von {total}"
|
||||
},
|
||||
"tag": {
|
||||
"empty": "Noch keine Tags",
|
||||
"empty_body": "Tags, die Sie in der App vergeben, erscheinen hier.",
|
||||
"messages": "{count, plural, one {Nachricht} other {Nachrichten}}",
|
||||
"total": "{count, plural, one {# Nachricht} other {# Nachrichten}}"
|
||||
},
|
||||
"tasks": {
|
||||
"all_done": "Alles erledigt",
|
||||
"empty": "Keine offenen Aufgaben",
|
||||
"mark_complete": "Als erledigt markieren",
|
||||
"mark_incomplete": "Als nicht erledigt markieren",
|
||||
"no_title": "(ohne Titel)",
|
||||
"off": "Aufgaben sind aus",
|
||||
"off_body": "Schalten Sie Aufgaben in den Kalendereinstellungen der App ein",
|
||||
"open_count": "{count} offen",
|
||||
"overdue": "Überfällig",
|
||||
"still_open": "{count} noch offen",
|
||||
"title": "Aufgaben",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen"
|
||||
},
|
||||
"vacation": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"subject": "Betreff: {subject}",
|
||||
"title": "Abwesenheitsnotiz",
|
||||
"unsupported": "Abwesenheitsnotizen sind nicht verfügbar",
|
||||
"until": "Bis {date}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"indigo": "Indigo",
|
||||
"teal": "Teal"
|
||||
},
|
||||
"detail": {
|
||||
"location_hint": "Opens the location; long-press to copy it"
|
||||
},
|
||||
"drawer": {
|
||||
"my_calendars": "My calendars",
|
||||
"no_calendars": "No calendars yet.",
|
||||
@@ -25,8 +28,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nothing planned. Tap to create.",
|
||||
"tap_to_create": "Tap + to create one"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Event",
|
||||
"task": "Task"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, agenda…",
|
||||
"location_placeholder": "Place or address",
|
||||
@@ -123,10 +131,16 @@
|
||||
"add_placeholder": "Add a task…",
|
||||
"add_time": "Add time",
|
||||
"clear_due_date": "Clear due date",
|
||||
"delete_confirm": "Delete this task?",
|
||||
"delete_error": "Failed to delete task",
|
||||
"not_found": "This task is no longer available.",
|
||||
"save_error": "Failed to save task",
|
||||
"update_error": "Failed to update task"
|
||||
},
|
||||
"views": {
|
||||
"n_days": "{count} days",
|
||||
"schedule": "Schedule",
|
||||
"three_days": "3 days"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -309,7 +323,9 @@
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"toolbar": {
|
||||
"background_color": "Background color",
|
||||
"remove_background_color": "Remove background color"
|
||||
"insert_image": "Insert image",
|
||||
"remove_background_color": "Remove background color",
|
||||
"remove_link": "Remove link"
|
||||
},
|
||||
"undo_send_label": "Sending in {seconds}s…",
|
||||
"upload_failed_short": "Upload failed",
|
||||
@@ -448,6 +464,7 @@
|
||||
"share_no_matches": "No matches",
|
||||
"share_no_users": "No other users found",
|
||||
"share_remove": "Remove access",
|
||||
"share_remove_confirm": "Remove access?",
|
||||
"share_role_custom": "Custom",
|
||||
"share_search_placeholder": "Search by name or email",
|
||||
"share_unavailable": "Sharing is not available on this device",
|
||||
@@ -610,6 +627,24 @@
|
||||
"send_now_failed": "Could not send now"
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"check_now": "Check now",
|
||||
"checked_at": "Checked {when}",
|
||||
"licences": "Open-source licences",
|
||||
"licences_desc": "AGPL-3.0 for the app, and the libraries it uses",
|
||||
"never_checked": "Not checked yet",
|
||||
"privacy": "Privacy",
|
||||
"privacy_desc": "What stays on this phone and what goes to your server",
|
||||
"report_problem": "Report a problem",
|
||||
"report_problem_desc": "Opens an issue on GitHub with the version filled in",
|
||||
"server": "Server",
|
||||
"server_value": "{host} · signed in as {user}",
|
||||
"source_code": "Source code",
|
||||
"this_device": "This device",
|
||||
"this_device_desc": "What the app is connected to and how to get help.",
|
||||
"up_to_date": "Up to date",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"account": {
|
||||
"accounts": {
|
||||
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
|
||||
@@ -636,6 +671,7 @@
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"description_mobile": "Customize how the app looks",
|
||||
"language": {
|
||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||
"system_default": "System default"
|
||||
@@ -644,9 +680,18 @@
|
||||
"description_mobile": "Display text labels next to toolbar icons."
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"description": "Move your settings to another phone or the webmail.",
|
||||
"export_import": "Export or import",
|
||||
"export_import_desc": "A JSON file, same format as the webmail",
|
||||
"reset": "Reset",
|
||||
"reset_desc": "Back to the defaults; mail stays",
|
||||
"title": "Settings backup"
|
||||
},
|
||||
"badges": {
|
||||
"experimental": "Experimental",
|
||||
"not_implemented": "Not implemented",
|
||||
"not_on_server": "Not on this server",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"contacts": {
|
||||
@@ -873,6 +918,7 @@
|
||||
"text_signature": "Plain-text signature"
|
||||
},
|
||||
"keywords": {
|
||||
"delete_confirm_title": "Delete tag \"{label}\"?",
|
||||
"description_mobile": "Colored tags to organize your mail.",
|
||||
"discover": {
|
||||
"add_all": "Add all",
|
||||
@@ -910,6 +956,12 @@
|
||||
"unified_cross_account": "Unified views across accounts",
|
||||
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
|
||||
},
|
||||
"licences": {
|
||||
"app_desc": "The app is free software under the {licence}. You can use, study, share and change it.",
|
||||
"libraries": "Libraries",
|
||||
"libraries_desc": "Open-source code the app is built with.",
|
||||
"read_licence": "Read the full licence text"
|
||||
},
|
||||
"message_list_order": {
|
||||
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
|
||||
"preset_chronological": "Newest first",
|
||||
@@ -975,23 +1027,31 @@
|
||||
"clear_title": "Clear offline mail",
|
||||
"days_ago": "{count} d ago",
|
||||
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
|
||||
"description_short": "Keep recent messages on this phone so they open without a connection.",
|
||||
"downloading": "Downloading {completed}/{total}…",
|
||||
"enabled": "Cache recent mail",
|
||||
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
|
||||
"hours_ago": "{count} h ago",
|
||||
"just_now": "just now",
|
||||
"keep_24h": "24 hours",
|
||||
"keep_days": "{count} days",
|
||||
"keep_from": "Keep mail from the last",
|
||||
"last_sync": "Last sync {when}",
|
||||
"last_sync_failed": "Last sync failed: {message}",
|
||||
"max_size": "Maximum size",
|
||||
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
|
||||
"messages_offline": "{count, plural, one {# message offline} other {# messages offline}}",
|
||||
"minutes_ago": "{count} min ago",
|
||||
"never": "never",
|
||||
"nothing_cached": "Nothing cached yet",
|
||||
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
|
||||
"scanning": "Scanning recent mail…",
|
||||
"sync_now": "Sync now",
|
||||
"synced": "Synced {when}",
|
||||
"syncing": "Syncing…",
|
||||
"title": "Offline mail",
|
||||
"use_at_most": "Use at most",
|
||||
"used_of": "{used} of {limit} MB",
|
||||
"window": "Window",
|
||||
"window_24h": "Last 24 hours",
|
||||
"window_days": "Last {count} days",
|
||||
@@ -1001,6 +1061,22 @@
|
||||
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
|
||||
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
|
||||
},
|
||||
"privacy": {
|
||||
"favicons": "Sender icons for business mail come from DuckDuckGo's icon service, which sees the sender's domain. Turn them off under Appearance → Sender favicons.",
|
||||
"no_tracking": "The app has no analytics and no ads.",
|
||||
"other_title": "Other connections",
|
||||
"phone_backup": "None of it goes into Android's cloud backup.",
|
||||
"phone_secure": "Your sign-in details are kept in the system's secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app's own storage.",
|
||||
"phone_title": "On this phone",
|
||||
"push": "When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.",
|
||||
"push_title": "Push notifications",
|
||||
"server": "Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.",
|
||||
"server_title": "Your mail server",
|
||||
"service_apple": "Apple's push service",
|
||||
"service_google": "Google's push service",
|
||||
"updates": "The app asks GitHub for the latest release to tell you about updates.",
|
||||
"updates_android": "The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates."
|
||||
},
|
||||
"security": {
|
||||
"client_cert": {
|
||||
"active": "Active: {alias}",
|
||||
@@ -1100,10 +1176,39 @@
|
||||
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
|
||||
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
|
||||
},
|
||||
"summary": {
|
||||
"events_off": "events off",
|
||||
"events_on": "events on",
|
||||
"mail_off": "Mail off",
|
||||
"mail_on": "Mail on",
|
||||
"offline_off": "Offline mail off",
|
||||
"offline_on": "Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB",
|
||||
"version": "Version {version}",
|
||||
"week_starts": "week starts {day}",
|
||||
"layout": "Quick actions, swipes, list order",
|
||||
"reading": "Read status, delete, attachments",
|
||||
"composing": "Signature, send delay, drafts",
|
||||
"identities": "Addresses you send from",
|
||||
"vacation": "Automatic reply while away",
|
||||
"filters": "Rules for incoming mail",
|
||||
"templates": "Reusable messages",
|
||||
"folders": "Create, rename, roles",
|
||||
"keywords": "Tag names and colours",
|
||||
"downloads": "File names for saved mail",
|
||||
"security": "Password, two-factor, app passwords",
|
||||
"content_senders": "Remote images, trusted senders",
|
||||
"contacts": "Address books, sorting",
|
||||
"files": "View, sorting, thumbnails",
|
||||
"sidebar_apps": "Web apps in the side menu",
|
||||
"updates": "Install new versions"
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Themes",
|
||||
"updates": "Updates"
|
||||
"updates": "Updates",
|
||||
"storage": "Storage & backup",
|
||||
"about": "About",
|
||||
"troubleshooting": "Troubleshooting"
|
||||
},
|
||||
"templates": {
|
||||
"delete_confirm": "Permanently delete \"{name}\"?",
|
||||
@@ -1123,6 +1228,12 @@
|
||||
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
|
||||
"title": "Themes"
|
||||
},
|
||||
"troubleshooting": {
|
||||
"cache_desc": "When a list looks stale or wrong.",
|
||||
"cache_title": "Cached data",
|
||||
"row_desc": "Refresh cached data, debug logging",
|
||||
"title": "Troubleshooting"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
||||
@@ -1135,6 +1246,11 @@
|
||||
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
|
||||
"remove_account_title": "Remove account?",
|
||||
"show_unread": "Show unread",
|
||||
"sign_out_all_confirm_message": "Every account is removed from this device. You can sign in again at any time.",
|
||||
"sign_out_all_confirm_title": "Sign out of all accounts?",
|
||||
"sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.",
|
||||
"sign_out_confirm_title": "Sign out?",
|
||||
"sign_out_confirm_title_account": "Sign out of {account}?",
|
||||
"tab_unavailable": "{name} (unavailable)"
|
||||
},
|
||||
"unified_mailbox": {
|
||||
@@ -1184,7 +1300,8 @@
|
||||
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
|
||||
"severity_deprecated": "Required",
|
||||
"severity_security": "Security",
|
||||
"title": "App updates"
|
||||
"title": "App updates",
|
||||
"verifying": "Verifying…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
@@ -1282,6 +1399,7 @@
|
||||
"in_outbox_short": "{count} waiting",
|
||||
"inbox": "Inbox",
|
||||
"more_unread": "+{count} more unread",
|
||||
"no_folders": "No folders to show",
|
||||
"no_starred": "No starred mail",
|
||||
"no_starred_body": "Star a message to keep it here.",
|
||||
"no_subject": "(no subject)",
|
||||
@@ -1292,6 +1410,13 @@
|
||||
"starred": "Starred",
|
||||
"unread": "unread"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Couldn't archive. Tap to try again.",
|
||||
"mark_read": "Couldn't mark as read. Tap to try again.",
|
||||
"rsvp": "Couldn't send your answer. Tap to try again.",
|
||||
"task": "Couldn't update “{title}”. Tap to try again.",
|
||||
"trash": "Couldn't delete. Tap to try again."
|
||||
},
|
||||
"people": {
|
||||
"empty": "No favourites yet",
|
||||
"empty_body": "The people you hear from most show up here.",
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enlace",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planificado. Toca para crear.",
|
||||
"tap_to_create": "Toca + para crear uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarea"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, orden del día…",
|
||||
"location_placeholder": "Lugar o dirección",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarea ya no está disponible.",
|
||||
"save_error": "No se pudo guardar la tarea",
|
||||
"update_error": "No se pudo actualizar la tarea"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 días"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
|
||||
"severity_deprecated": "Obligatoria",
|
||||
"severity_security": "Seguridad",
|
||||
"title": "Actualizaciones de la app"
|
||||
"title": "Actualizaciones de la app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "لینک",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "برنامهای نیست. برای ایجاد ضربه بزنید.",
|
||||
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
|
||||
},
|
||||
"fab": {
|
||||
"event": "رویداد",
|
||||
"task": "کار"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "یادداشتها، دستور جلسه…",
|
||||
"location_placeholder": "مکان یا نشانی",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "این وظیفه دیگر در دسترس نیست.",
|
||||
"save_error": "ذخیره وظیفه ناموفق بود",
|
||||
"update_error": "بهروزرسانی وظیفه ناموفق بود"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "برنامه",
|
||||
"three_days": "۳ روز"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lien",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Rien de prévu. Touchez pour créer.",
|
||||
"tap_to_create": "Appuyez sur + pour en créer un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Événement",
|
||||
"task": "Tâche"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre du jour…",
|
||||
"location_placeholder": "Lieu ou adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Cette tâche n'est plus disponible.",
|
||||
"save_error": "Échec de l'enregistrement de la tâche",
|
||||
"update_error": "Échec de la mise à jour de la tâche"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 jours"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
|
||||
"severity_deprecated": "Requise",
|
||||
"severity_security": "Sécurité",
|
||||
"title": "Mises à jour de l'application"
|
||||
"title": "Mises à jour de l'application",
|
||||
"verifying": "Vérification…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "קישור",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
|
||||
"tap_to_create": "הקש על + כדי ליצור"
|
||||
},
|
||||
"fab": {
|
||||
"event": "אירוע",
|
||||
"task": "משימה"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "הערות, סדר יום…",
|
||||
"location_placeholder": "מקום או כתובת",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "המשימה הזו אינה זמינה עוד.",
|
||||
"save_error": "שמירת המשימה נכשלה",
|
||||
"update_error": "עדכון המשימה נכשל"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "לוח זמנים",
|
||||
"three_days": "3 ימים"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Hivatkozás",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.",
|
||||
"tap_to_create": "Esemény létrehozásához koppints a + gombra"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esemény",
|
||||
"task": "Feladat"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Jegyzetek, napirend…",
|
||||
"location_placeholder": "Hely vagy cím",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Ez a feladat már nem érhető el.",
|
||||
"save_error": "Nem sikerült menteni a feladatot",
|
||||
"update_error": "Nem sikerült frissíteni a feladatot"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Ütemezés",
|
||||
"three_days": "3 nap"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
|
||||
"severity_deprecated": "Kötelező",
|
||||
"severity_security": "Biztonsági",
|
||||
"title": "Alkalmazásfrissítések"
|
||||
"title": "Alkalmazásfrissítések",
|
||||
"verifying": "Ellenőrzés…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niente in programma. Tocca per creare.",
|
||||
"tap_to_create": "Tocca + per crearne uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Attività"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, ordine del giorno…",
|
||||
"location_placeholder": "Luogo o indirizzo",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Questa attività non è più disponibile.",
|
||||
"save_error": "Impossibile salvare l'attività",
|
||||
"update_error": "Impossibile aggiornare l'attività"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programma",
|
||||
"three_days": "3 giorni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
|
||||
"severity_deprecated": "Obbligatorio",
|
||||
"severity_security": "Sicurezza",
|
||||
"title": "Aggiornamenti dell'app"
|
||||
"title": "Aggiornamenti dell'app",
|
||||
"verifying": "Verifica in corso…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "リンク",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "予定はありません。タップして作成",
|
||||
"tap_to_create": "+ をタップして作成"
|
||||
},
|
||||
"fab": {
|
||||
"event": "予定",
|
||||
"task": "タスク"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "メモ、議題…",
|
||||
"location_placeholder": "場所または住所",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "このタスクは利用できなくなりました。",
|
||||
"save_error": "タスクの保存に失敗しました",
|
||||
"update_error": "タスクの更新に失敗しました"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "スケジュール",
|
||||
"three_days": "3 日"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "링크",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
|
||||
"tap_to_create": "+를 눌러 만들어 보세요"
|
||||
},
|
||||
"fab": {
|
||||
"event": "일정",
|
||||
"task": "할 일"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "메모, 안건…",
|
||||
"location_placeholder": "장소 또는 주소",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "이 할 일을 더 이상 찾을 수 없어요.",
|
||||
"save_error": "할 일을 저장하지 못했어요",
|
||||
"update_error": "할 일을 업데이트하지 못했어요"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "일정",
|
||||
"three_days": "3일"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Saite",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.",
|
||||
"tap_to_create": "Pieskarieties +, lai izveidotu notikumu"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Notikums",
|
||||
"task": "Uzdevums"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Piezīmes, darba kārtība…",
|
||||
"location_placeholder": "Vieta vai adrese",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Šis uzdevums vairs nav pieejams.",
|
||||
"save_error": "Neizdevās saglabāt uzdevumu",
|
||||
"update_error": "Neizdevās atjaunināt uzdevumu"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Grafiks",
|
||||
"three_days": "3 dienas"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
|
||||
"severity_deprecated": "Obligāts",
|
||||
"severity_security": "Drošība",
|
||||
"title": "Lietotnes atjauninājumi"
|
||||
"title": "Lietotnes atjauninājumi",
|
||||
"verifying": "Pārbauda…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Холбоос",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
|
||||
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Үйл явдал",
|
||||
"task": "Даалгавар"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…",
|
||||
"location_placeholder": "Газар эсвэл хаяг",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Энэ даалгавар байхгүй болсон.",
|
||||
"save_error": "Даалгаврыг хадгалж чадсангүй",
|
||||
"update_error": "Даалгаврыг шинэчилж чадсангүй"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Хуваарь",
|
||||
"three_days": "3 өдөр"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lenke",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ingenting planlagt. Trykk for å opprette.",
|
||||
"tap_to_create": "Trykk på + for å opprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Hendelse",
|
||||
"task": "Oppgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notater, agenda…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne oppgaven er ikke lenger tilgjengelig.",
|
||||
"save_error": "Kunne ikke lagre oppgaven",
|
||||
"update_error": "Kunne ikke oppdatere oppgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Timeplan",
|
||||
"three_days": "3 dager"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
|
||||
"severity_deprecated": "Påkrevd",
|
||||
"severity_security": "Sikkerhet",
|
||||
"title": "Appoppdateringer"
|
||||
"title": "Appoppdateringer",
|
||||
"verifying": "Verifiserer…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niets gepland. Tik om aan te maken.",
|
||||
"tap_to_create": "Tik op + om er een te maken"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Afspraak",
|
||||
"task": "Taak"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notities, agendapunten…",
|
||||
"location_placeholder": "Plaats of adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Deze taak is niet meer beschikbaar.",
|
||||
"save_error": "Taak opslaan mislukt",
|
||||
"update_error": "Taak bijwerken mislukt"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 dagen"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
|
||||
"severity_deprecated": "Vereist",
|
||||
"severity_security": "Beveiliging",
|
||||
"title": "App-updates"
|
||||
"title": "App-updates",
|
||||
"verifying": "Controleren…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.",
|
||||
"tap_to_create": "Stuknij +, aby utworzyć wydarzenie"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Wydarzenie",
|
||||
"task": "Zadanie"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notatki, agenda…",
|
||||
"location_placeholder": "Miejsce lub adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "To zadanie nie jest już dostępne.",
|
||||
"save_error": "Nie udało się zapisać zadania",
|
||||
"update_error": "Nie udało się zaktualizować zadania"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Harmonogram",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
|
||||
"severity_deprecated": "Wymagana",
|
||||
"severity_security": "Bezpieczeństwo",
|
||||
"title": "Aktualizacje aplikacji"
|
||||
"title": "Aktualizacje aplikacji",
|
||||
"verifying": "Weryfikowanie…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planejado. Toque para criar.",
|
||||
"tap_to_create": "Toque em + para criar um"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarefa"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, pauta…",
|
||||
"location_placeholder": "Local ou endereço",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarefa não está mais disponível.",
|
||||
"save_error": "Falha ao salvar a tarefa",
|
||||
"update_error": "Falha ao atualizar a tarefa"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programação",
|
||||
"three_days": "3 dias"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
|
||||
"severity_deprecated": "Obrigatória",
|
||||
"severity_security": "Segurança",
|
||||
"title": "Atualizações do app"
|
||||
"title": "Atualizações do app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nimic planificat. Atinge pentru a crea.",
|
||||
"tap_to_create": "Atingeți + pentru a crea unul"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Eveniment",
|
||||
"task": "Sarcină"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, agendă…",
|
||||
"location_placeholder": "Loc sau adresă",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Această sarcină nu mai este disponibilă.",
|
||||
"save_error": "Sarcina nu a putut fi salvată",
|
||||
"update_error": "Sarcina nu a putut fi actualizată"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 zile"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
|
||||
"severity_deprecated": "Obligatorie",
|
||||
"severity_security": "Securitate",
|
||||
"title": "Actualizări aplicație"
|
||||
"title": "Actualizări aplicație",
|
||||
"verifying": "Se verifică…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Ссылка",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
|
||||
"tap_to_create": "Нажмите +, чтобы создать"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Мероприятие",
|
||||
"task": "Задача"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Заметки, повестка…",
|
||||
"location_placeholder": "Место или адрес",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Эта задача больше недоступна.",
|
||||
"save_error": "Не удалось сохранить задачу",
|
||||
"update_error": "Не удалось обновить задачу"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Расписание",
|
||||
"three_days": "3 дня"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.",
|
||||
"tap_to_create": "Udalosť vytvoríte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Udalosť",
|
||||
"task": "Úloha"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Miesto alebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Táto úloha už nie je k dispozícii.",
|
||||
"save_error": "Úlohu sa nepodarilo uložiť",
|
||||
"update_error": "Úlohu sa nepodarilo aktualizovať"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečenie",
|
||||
"title": "Aktualizácie aplikácie"
|
||||
"title": "Aktualizácie aplikácie",
|
||||
"verifying": "Overovanie…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Bağlantı",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.",
|
||||
"tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Etkinlik",
|
||||
"task": "Görev"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notlar, gündem…",
|
||||
"location_placeholder": "Yer veya adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Bu görev artık kullanılamıyor.",
|
||||
"save_error": "Görev kaydedilemedi",
|
||||
"update_error": "Görev güncellenemedi"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 gün"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
|
||||
"severity_deprecated": "Zorunlu",
|
||||
"severity_security": "Güvenlik",
|
||||
"title": "Uygulama güncellemeleri"
|
||||
"title": "Uygulama güncellemeleri",
|
||||
"verifying": "Doğrulanıyor…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Посилання",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
|
||||
"tap_to_create": "Натисніть +, щоб створити"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Подія",
|
||||
"task": "Завдання"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Нотатки, порядок денний…",
|
||||
"location_placeholder": "Місце або адреса",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Це завдання більше недоступне.",
|
||||
"save_error": "Не вдалося зберегти завдання",
|
||||
"update_error": "Не вдалося оновити завдання"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Розклад",
|
||||
"three_days": "3 дні"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "連結",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "沒有安排。輕觸即可建立。",
|
||||
"tap_to_create": "點選 + 即可建立"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活動",
|
||||
"task": "工作"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "備註、議程…",
|
||||
"location_placeholder": "地點或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任務已不存在。",
|
||||
"save_error": "儲存任務失敗",
|
||||
"update_error": "更新任務失敗"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "行程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "链接",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "暂无安排。点按即可创建。",
|
||||
"tap_to_create": "点按 + 创建"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活动",
|
||||
"task": "任务"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "备注、议程…",
|
||||
"location_placeholder": "地点或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任务已不存在。",
|
||||
"save_error": "保存任务失败",
|
||||
"update_error": "更新任务失败"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "日程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1168,6 +1177,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,4 @@ const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// babel.config.js rewrites lucide-react-native imports to per-icon modules
|
||||
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
|
||||
// `exports` map only lists its barrel, so resolve those paths as plain files
|
||||
// instead of letting Metro warn about every one of them.
|
||||
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
|
||||
const upstreamResolveRequest = config.resolver.resolveRequest;
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
const resolve = upstreamResolveRequest ?? context.resolveRequest;
|
||||
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
|
||||
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
|
||||
}
|
||||
return resolve(context, moduleName, platform);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||