Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
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 | ||
|
|
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
|
||||
@@ -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,8 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(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,143 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
// The preview's folder is gone once it closed; a page
|
||||
// still queued then must not bring it back.
|
||||
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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,
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
@@ -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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
|
||||
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.",
|
||||
"archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
|
||||
"archive_first_files": "أول {count} ملف",
|
||||
"archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Obre en una aplicació",
|
||||
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
|
||||
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# fitxer} other {# fitxers}}",
|
||||
"archive_first_files": "Els primers {count} fitxers",
|
||||
"archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"archive_unreadable": "Aquest arxiu no es pot obrir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otevřít v aplikaci",
|
||||
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici."
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát.",
|
||||
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit.",
|
||||
"archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
|
||||
"archive_first_files": "Prvních {count} souborů",
|
||||
"archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"archive_unreadable": "Tento archiv zde nelze otevřít."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Åbn i app",
|
||||
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her.",
|
||||
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filer",
|
||||
"archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.",
|
||||
"archive_unreadable": "Dette arkiv kan ikke åbnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
@@ -1168,6 +1184,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...",
|
||||
@@ -398,7 +413,14 @@
|
||||
"preview": {
|
||||
"open_external": "In App öffnen",
|
||||
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp."
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
|
||||
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.",
|
||||
"archive_files": "{count, plural, one {# Datei} other {# Dateien}}",
|
||||
"archive_first_files": "Die ersten {count} Dateien",
|
||||
"archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.",
|
||||
"archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
@@ -438,6 +460,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 +655,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 +882,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 +1005,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 +1121,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 +1148,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 +1236,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 +1290,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",
|
||||
@@ -408,7 +424,14 @@
|
||||
"preview": {
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type."
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here.",
|
||||
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
|
||||
"pdf_unreadable": "This PDF can't be shown here.",
|
||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||
"archive_first_files": "The first {count} files",
|
||||
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
|
||||
"archive_unreadable": "This archive can't be opened here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
@@ -448,6 +471,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 +634,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 +678,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 +687,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 +925,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 +963,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 +1034,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 +1068,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 +1183,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 +1235,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 +1253,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 +1307,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 +1406,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 +1417,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir en una app",
|
||||
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
|
||||
"unsupported": "No hay vista previa para este tipo de archivo."
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí.",
|
||||
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# archivo} other {# archivos}}",
|
||||
"archive_first_files": "Los primeros {count} archivos",
|
||||
"archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"archive_unreadable": "Este archivo comprimido no se puede abrir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد."
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود.",
|
||||
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود.",
|
||||
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
|
||||
"archive_first_files": "{count} فایل نخست",
|
||||
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"archive_unreadable": "این بایگانی اینجا باز نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Ouvrir dans une app",
|
||||
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier."
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
|
||||
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici.",
|
||||
"archive_files": "{count, plural, one {# fichier} other {# fichiers}}",
|
||||
"archive_first_files": "Les {count} premiers fichiers",
|
||||
"archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.",
|
||||
"archive_unreadable": "Cette archive ne peut pas être ouverte ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
|
||||
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
|
||||
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
|
||||
"archive_first_files": "{count} הקבצים הראשונים",
|
||||
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Megnyitás alkalmazásban",
|
||||
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le.",
|
||||
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.",
|
||||
"archive_files": "{count, plural, one {# fájl} other {# fájl}}",
|
||||
"archive_first_files": "Az első {count} fájl",
|
||||
"archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"archive_unreadable": "Ez az archívum itt nem nyitható meg."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Apri in un'app",
|
||||
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file."
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui.",
|
||||
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui.",
|
||||
"archive_files": "{count, plural, one {# file} other {# file}}",
|
||||
"archive_first_files": "I primi {count} file",
|
||||
"archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"archive_unreadable": "Questo archivio non può essere aperto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。"
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。",
|
||||
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。",
|
||||
"archive_files": "{count, plural, other {# 個のファイル}}",
|
||||
"archive_first_files": "最初の {count} 個のファイル",
|
||||
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"archive_unreadable": "このアーカイブはここでは開けません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
|
||||
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
|
||||
"archive_files": "{count, plural, other {파일 #개}}",
|
||||
"archive_first_files": "처음 {count}개 파일",
|
||||
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Atvērt lietotnē",
|
||||
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma."
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot.",
|
||||
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt.",
|
||||
"archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
|
||||
"archive_first_files": "Pirmie {count} faili",
|
||||
"archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"archive_unreadable": "Šo arhīvu šeit nevar atvērt."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
|
||||
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
|
||||
"archive_files": "{count, plural, one {# файл} other {# файл}}",
|
||||
"archive_first_files": "Эхний {count} файл",
|
||||
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Åpne i app",
|
||||
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her.",
|
||||
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filene",
|
||||
"archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.",
|
||||
"archive_unreadable": "Dette arkivet kan ikke åpnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Openen in app",
|
||||
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype."
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
|
||||
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.",
|
||||
"archive_files": "{count, plural, one {# bestand} other {# bestanden}}",
|
||||
"archive_first_files": "De eerste {count} bestanden",
|
||||
"archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.",
|
||||
"archive_unreadable": "Dit archief kan hier niet worden geopend."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otwórz w aplikacji",
|
||||
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
|
||||
"unsupported": "Brak podglądu dla tego typu pliku."
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
|
||||
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.",
|
||||
"archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
|
||||
"archive_first_files": "Pierwsze {count} plików",
|
||||
"archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.",
|
||||
"archive_unreadable": "Tego archiwum nie można tutaj otworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir em outro app",
|
||||
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo."
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
|
||||
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui.",
|
||||
"archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}",
|
||||
"archive_first_files": "Os primeiros {count} ficheiros",
|
||||
"archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"archive_unreadable": "Este arquivo não pode ser aberto aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Deschideți într-o aplicație",
|
||||
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici.",
|
||||
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici.",
|
||||
"archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
|
||||
"archive_first_files": "Primele {count} fișiere",
|
||||
"archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.",
|
||||
"archive_unreadable": "Această arhivă nu poate fi deschisă aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра."
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
|
||||
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
|
||||
"archive_first_files": "Первые {count} файлов",
|
||||
"archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"archive_unreadable": "Этот архив нельзя открыть здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otvoriť v aplikácii",
|
||||
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
|
||||
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť.",
|
||||
"archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
|
||||
"archive_first_files": "Prvých {count} súborov",
|
||||
"archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"archive_unreadable": "Tento archív tu nie je možné otvoriť."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Uygulamada aç",
|
||||
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
|
||||
"unsupported": "Bu dosya türü için önizleme yok."
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor.",
|
||||
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor.",
|
||||
"archive_files": "{count, plural, one {# dosya} other {# dosya}}",
|
||||
"archive_first_files": "İlk {count} dosya",
|
||||
"archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"archive_unreadable": "Bu arşiv burada açılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду."
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут.",
|
||||
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
|
||||
"archive_first_files": "Перші {count} файлів",
|
||||
"archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"archive_unreadable": "Цей архів не можна відкрити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。"
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。",
|
||||
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。",
|
||||
"archive_files": "{count, plural, other {# 個檔案}}",
|
||||
"archive_first_files": "前 {count} 個檔案",
|
||||
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"archive_unreadable": "無法在此開啟這個壓縮檔。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
@@ -1168,6 +1184,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": {
|
||||
@@ -398,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。"
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。",
|
||||
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
|
||||
"pdf_unreadable": "无法在此显示该 PDF。",
|
||||
"archive_files": "{count, plural, other {# 个文件}}",
|
||||
"archive_first_files": "前 {count} 个文件",
|
||||
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
|
||||
"archive_unreadable": "无法在此打开该压缩包。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
@@ -1168,6 +1184,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;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.82",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.82",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -33,7 +34,6 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
@@ -3803,6 +3804,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -6181,6 +6189,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flow-enums-runtime": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
|
||||
@@ -6226,6 +6244,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -7300,6 +7333,33 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7312,6 +7372,29 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
@@ -7324,6 +7407,16 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -7697,17 +7790,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react-native": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.7.0.tgz",
|
||||
"integrity": "sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -8601,6 +8683,138 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/patch-package/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -10264,6 +10478,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -10392,6 +10616,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.82",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -42,7 +43,6 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -56,6 +56,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
|
||||
index 93c2aea..ba6e3f1 100644
|
||||
--- a/node_modules/postal-mime/src/base64-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/base64-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { decodeBase64, concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
@@ -49,6 +49,6 @@ export default class Base64Decoder {
|
||||
finalize() {
|
||||
this.flushRemainder();
|
||||
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
|
||||
index a5e058e..f255e8d 100644
|
||||
--- a/node_modules/postal-mime/src/decode-strings.js
|
||||
+++ b/node_modules/postal-mime/src/decode-strings.js
|
||||
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
|
||||
+// the decoders join their chunks here instead of through a Blob.
|
||||
+export function concatChunks(chunks) {
|
||||
+ const parts = chunks.map(chunk => {
|
||||
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
|
||||
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
|
||||
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
+ });
|
||||
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
|
||||
+ let pos = 0;
|
||||
+ for (const part of parts) {
|
||||
+ out.set(part, pos);
|
||||
+ pos += part.byteLength;
|
||||
+ }
|
||||
+ return out.buffer;
|
||||
+}
|
||||
+
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
index ad1c222..1e1d012 100644
|
||||
--- a/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
|
||||
index adee72c..3973673 100644
|
||||
--- a/node_modules/postal-mime/src/qp-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/qp-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
|
||||
+import { concatChunks, hexNibble } from './decode-strings.js';
|
||||
|
||||
const CHR_EQUALS = 0x3d;
|
||||
const CHR_LF = 0x0a;
|
||||
@@ -83,6 +83,6 @@ export default class QPDecoder {
|
||||
this.flushBuffer();
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
index 1c12d1f..60f01e7 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
@@ -207,7 +207,7 @@ public class RNWidget {
|
||||
intent.putExtra("clickActionData", Arguments.toBundle(clickableView.getClickActionData()));
|
||||
PendingIntent pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
index ed1af56..12c3630 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
@@ -162,7 +162,7 @@ class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
|
||||
intent.putExtra("clickActionData", clickableArea.getBundle("clickActionData"));
|
||||
return PendingIntent.getBroadcast(
|
||||
mContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
index 2a34305..711503b 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
@@ -15,8 +15,23 @@ import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
import java.util.List;
|
||||
+import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class RNWidgetUtil {
|
||||
+ private static final AtomicInteger lastRequestCode = new AtomicInteger((int) System.currentTimeMillis() - 1);
|
||||
+
|
||||
+ /**
|
||||
+ * A request code no other click PendingIntent of this app holds. Every click intent has the same
|
||||
+ * action and component, so the request code alone tells them apart: a code shared by two buttons
|
||||
+ * (two registrations within one millisecond with the time as the code) made the second one cancel
|
||||
+ * the first, leaving a button that does nothing. Counting up from the time keeps the codes unique
|
||||
+ * in this process and clear of the ones an earlier process left on the launcher.
|
||||
+ */
|
||||
+ public static int nextClickRequestCode() {
|
||||
+ int now = (int) System.currentTimeMillis();
|
||||
+ return lastRequestCode.updateAndGet(last -> now - last > 0 ? now : last + 1);
|
||||
+ }
|
||||
+
|
||||
public static int getWidgetWidth(Context context, int widgetId) {
|
||||
boolean isPortrait = context.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT;
|
||||
if (isPortrait) {
|
||||
@@ -1,91 +0,0 @@
|
||||
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
|
||||
// the per-icon modules they re-export, so the bundle only contains (and the
|
||||
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
|
||||
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
|
||||
// in and run every icon at startup.
|
||||
//
|
||||
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
|
||||
// becomes
|
||||
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
|
||||
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
|
||||
//
|
||||
// The name -> file table is read from the package's own ESM barrel, so icon
|
||||
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
|
||||
// through the barrel. metro.config.js resolves these deep paths, which the
|
||||
// package's `exports` map does not list.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PACKAGE = 'lucide-react-native';
|
||||
const ESM_DIR = 'dist/esm';
|
||||
|
||||
/** Map every name the barrel exports to `{ source, imported }`. */
|
||||
function loadExportMap() {
|
||||
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
|
||||
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
|
||||
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
|
||||
const map = new Map();
|
||||
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
|
||||
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
|
||||
for (const spec of m[1].split(',')) {
|
||||
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
|
||||
if (imported) map.set(exported, { source, imported });
|
||||
}
|
||||
}
|
||||
if (map.size === 0) {
|
||||
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let exportMap = null;
|
||||
function getExportMap() {
|
||||
if (!exportMap) exportMap = loadExportMap();
|
||||
return exportMap;
|
||||
}
|
||||
|
||||
// A value-less binding (only used as a type) is dropped by the TypeScript
|
||||
// transform anyway; treat it the same way instead of failing the build.
|
||||
function isOnlyUsedAsType(importPath, localName) {
|
||||
const binding = importPath.scope.getBinding(localName);
|
||||
if (!binding) return true;
|
||||
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
|
||||
}
|
||||
|
||||
function lucideImportsPlugin({ types: t }) {
|
||||
return {
|
||||
name: 'lucide-direct-imports',
|
||||
visitor: {
|
||||
ImportDeclaration(importPath) {
|
||||
const { node } = importPath;
|
||||
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
|
||||
// Side-effect, default and namespace imports keep using the barrel.
|
||||
if (node.specifiers.length === 0) return;
|
||||
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
|
||||
|
||||
const map = getExportMap();
|
||||
const replacements = [];
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.importKind === 'type') continue;
|
||||
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
|
||||
const target = map.get(name);
|
||||
if (!target) {
|
||||
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
|
||||
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
|
||||
}
|
||||
const local = t.identifier(spec.local.name);
|
||||
const specifier = target.imported === 'default'
|
||||
? t.importDefaultSpecifier(local)
|
||||
: t.importSpecifier(local, t.identifier(target.imported));
|
||||
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
|
||||
}
|
||||
if (replacements.length === 0) importPath.remove();
|
||||
else importPath.replaceWithMultiple(replacements);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = lucideImportsPlugin;
|
||||
module.exports.loadExportMap = loadExportMap;
|
||||
module.exports.PACKAGE = PACKAGE;
|
||||
@@ -0,0 +1,241 @@
|
||||
// Writes src/components/icons/index.ts from Tabler's outline path data.
|
||||
//
|
||||
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
|
||||
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
|
||||
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
|
||||
//
|
||||
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
|
||||
//
|
||||
// The default path is the webmail checkout this repo usually sits in
|
||||
// (repos/react-native inside jmap-webmail), whose node_modules has
|
||||
// @tabler/icons. Add a line to MAP to make a new icon importable.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = process.argv[2]
|
||||
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
|
||||
|
||||
// App name -> Tabler outline name. Where the webmail (components/icons) maps
|
||||
// the same name, this uses its choice.
|
||||
const MAP = {
|
||||
AlarmClock: 'alarm',
|
||||
AlertCircle: 'alert-circle',
|
||||
AlertTriangle: 'alert-triangle',
|
||||
AlignCenter: 'align-center',
|
||||
AlignLeft: 'align-left',
|
||||
AlignRight: 'align-right',
|
||||
Archive: 'archive',
|
||||
ArrowDown: 'arrow-down',
|
||||
ArrowDownWideNarrow: 'sort-descending',
|
||||
ArrowLeft: 'arrow-left',
|
||||
ArrowRight: 'arrow-right',
|
||||
ArrowUp: 'arrow-up',
|
||||
ArrowUpDown: 'arrows-sort',
|
||||
ArrowUpNarrowWide: 'sort-ascending',
|
||||
Ban: 'ban',
|
||||
Bell: 'bell',
|
||||
BellRing: 'bell-ringing',
|
||||
Bold: 'bold',
|
||||
Book: 'book-2',
|
||||
BookOpen: 'book',
|
||||
BookUser: 'address-book',
|
||||
Bug: 'bug',
|
||||
Building: 'building',
|
||||
Cake: 'cake',
|
||||
Calendar: 'calendar',
|
||||
CalendarDays: 'calendar-month',
|
||||
CalendarPlus: 'calendar-plus',
|
||||
Camera: 'camera',
|
||||
Check: 'check',
|
||||
CheckCheck: 'checks',
|
||||
CheckCircle: 'circle-check',
|
||||
CheckCircle2: 'circle-check',
|
||||
CheckSquare: 'square-check',
|
||||
ChevronDown: 'chevron-down',
|
||||
ChevronLeft: 'chevron-left',
|
||||
ChevronRight: 'chevron-right',
|
||||
ChevronUp: 'chevron-up',
|
||||
CircleCheck: 'circle-check',
|
||||
ClipboardPaste: 'clipboard-text',
|
||||
Clock: 'clock',
|
||||
CloudDownload: 'cloud-download',
|
||||
CloudOff: 'cloud-off',
|
||||
Code: 'code',
|
||||
Columns3: 'columns-3',
|
||||
Columns4: 'layout-columns',
|
||||
Copy: 'copy',
|
||||
Database: 'database',
|
||||
Download: 'download',
|
||||
Edit2: 'pencil',
|
||||
Eraser: 'eraser',
|
||||
ExternalLink: 'external-link',
|
||||
Eye: 'eye',
|
||||
EyeOff: 'eye-off',
|
||||
File: 'file',
|
||||
FileArchive: 'file-zip',
|
||||
FileAudio: 'file-music',
|
||||
FileCode: 'file-code',
|
||||
FileCode2: 'file-code-2',
|
||||
FileImage: 'photo',
|
||||
FileInput: 'file-import',
|
||||
FileSpreadsheet: 'file-spreadsheet',
|
||||
FileText: 'file-text',
|
||||
FileVideo: 'video',
|
||||
Filter: 'filter',
|
||||
Flag: 'flag',
|
||||
FlaskConical: 'flask',
|
||||
Folder: 'folder',
|
||||
FolderArchive: 'archive',
|
||||
FolderInput: 'folder-down',
|
||||
FolderOpen: 'folder-open',
|
||||
FolderPlus: 'folder-plus',
|
||||
FolderSync: 'folders',
|
||||
Forward: 'arrow-forward-up',
|
||||
Globe: 'world',
|
||||
Grid3x3: 'grid-dots',
|
||||
GripVertical: 'grip-vertical',
|
||||
HardDrive: 'server-2',
|
||||
Heading1: 'h-1',
|
||||
Heading2: 'h-2',
|
||||
Heart: 'heart',
|
||||
HelpCircle: 'help-circle',
|
||||
Highlighter: 'highlight',
|
||||
Image: 'photo',
|
||||
Import: 'file-import',
|
||||
Inbox: 'inbox',
|
||||
Info: 'info-circle',
|
||||
Italic: 'italic',
|
||||
Key: 'key',
|
||||
KeyRound: 'key',
|
||||
Languages: 'language',
|
||||
Layers: 'stack',
|
||||
LayoutGrid: 'layout-grid',
|
||||
LayoutTemplate: 'template',
|
||||
Link2: 'link',
|
||||
Link2Off: 'unlink',
|
||||
List: 'list',
|
||||
ListChecks: 'list-check',
|
||||
ListOrdered: 'list-numbers',
|
||||
Lock: 'lock',
|
||||
LogOut: 'logout',
|
||||
Mail: 'mail',
|
||||
MailCheck: 'mail-check',
|
||||
MailMinus: 'mail-minus',
|
||||
MailOpen: 'mail-opened',
|
||||
Mails: 'inbox',
|
||||
MapPin: 'map-pin',
|
||||
Maximize2: 'maximize',
|
||||
Menu: 'menu-2',
|
||||
MessageSquare: 'message',
|
||||
Minus: 'minus',
|
||||
Monitor: 'device-desktop',
|
||||
Moon: 'moon',
|
||||
MoreHorizontal: 'dots',
|
||||
MoreVertical: 'dots-vertical',
|
||||
MoveVertical: 'arrows-vertical',
|
||||
Palette: 'palette',
|
||||
Palmtree: 'beach',
|
||||
PanelLeftClose: 'layout-sidebar-left-collapse',
|
||||
PanelRight: 'layout-sidebar-right',
|
||||
Paperclip: 'paperclip',
|
||||
PenLine: 'pencil',
|
||||
PenSquare: 'edit',
|
||||
Pencil: 'pencil',
|
||||
Phone: 'phone',
|
||||
Pin: 'pinned',
|
||||
PinOff: 'pinned-off',
|
||||
Plus: 'plus',
|
||||
Puzzle: 'puzzle',
|
||||
QrCode: 'qrcode',
|
||||
Quote: 'quote',
|
||||
Redo2: 'arrow-forward',
|
||||
RefreshCw: 'refresh',
|
||||
RemoveFormatting: 'clear-formatting',
|
||||
Repeat: 'repeat',
|
||||
Reply: 'arrow-back-up',
|
||||
ReplyAll: 'arrow-back-up-double',
|
||||
RotateCcw: 'rotate',
|
||||
Rows2: 'layout-rows',
|
||||
Rss: 'rss',
|
||||
Save: 'device-floppy',
|
||||
ScanSearch: 'zoom-scan',
|
||||
Scale: 'scale',
|
||||
Search: 'search',
|
||||
Send: 'send',
|
||||
Server: 'server',
|
||||
Settings: 'settings',
|
||||
Share2: 'share-2',
|
||||
Shield: 'shield',
|
||||
ShieldAlert: 'shield-exclamation',
|
||||
ShieldCheck: 'shield-check',
|
||||
ShieldQuestion: 'shield-question',
|
||||
Shuffle: 'arrows-shuffle',
|
||||
Smartphone: 'device-mobile',
|
||||
Square: 'square',
|
||||
SquareCheck: 'square-check',
|
||||
SquarePen: 'edit',
|
||||
Star: 'star',
|
||||
StickyNote: 'note',
|
||||
Strikethrough: 'strikethrough',
|
||||
Sun: 'sun',
|
||||
Table: 'table',
|
||||
Tag: 'tag',
|
||||
Tags: 'tags',
|
||||
Terminal: 'terminal',
|
||||
Trash: 'trash',
|
||||
Trash2: 'trash',
|
||||
Type: 'typography',
|
||||
Underline: 'underline',
|
||||
Undo2: 'arrow-back',
|
||||
Upload: 'upload',
|
||||
User: 'user',
|
||||
UserCircle: 'user-circle',
|
||||
UserMinus: 'user-minus',
|
||||
UserPen: 'user-edit',
|
||||
UserPlus: 'user-plus',
|
||||
UserRound: 'user',
|
||||
UserRoundPlus: 'user-plus',
|
||||
Users: 'users',
|
||||
Video: 'video',
|
||||
X: 'x',
|
||||
XCircle: 'circle-x',
|
||||
};
|
||||
|
||||
function main() {
|
||||
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
|
||||
const glyphs = new Map();
|
||||
const missing = [];
|
||||
for (const [appName, tabler] of Object.entries(MAP)) {
|
||||
const icon = nodes[tabler];
|
||||
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
|
||||
const paths = icon.map(([tag, attrs]) => {
|
||||
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
|
||||
return attrs.d;
|
||||
});
|
||||
glyphs.set(tabler, paths);
|
||||
}
|
||||
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
|
||||
|
||||
const lines = [
|
||||
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
|
||||
'// Do not edit by hand: add the icon to MAP in the script and run it.',
|
||||
'',
|
||||
"import { makeIcon } from './icon';",
|
||||
'',
|
||||
"export type { AppIcon, IconProps } from './icon';",
|
||||
'',
|
||||
'const G = {',
|
||||
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
|
||||
'} as const;',
|
||||
'',
|
||||
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
|
||||
'',
|
||||
];
|
||||
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
|
||||
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,46 @@
|
||||
// Writes src/lib/licences.json: the libraries the app ships (the runtime
|
||||
// dependencies in package.json) with their licence, for Settings → About →
|
||||
// Open-source licences. Run after adding or upgrading a dependency:
|
||||
//
|
||||
// node scripts/generate-licences.mjs
|
||||
//
|
||||
// The test in src/lib/__tests__/licences.test.ts fails when a dependency is
|
||||
// missing from the list.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
|
||||
// Code that isn't an npm dependency but ships in the app.
|
||||
const VENDORED = [
|
||||
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
|
||||
];
|
||||
|
||||
function repoUrl(meta) {
|
||||
const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url;
|
||||
const url = meta.homepage || repo || '';
|
||||
return url
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^git:\/\//, 'https://')
|
||||
.replace(/^github:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/#.*$/, '');
|
||||
}
|
||||
|
||||
const libraries = Object.keys(pkg.dependencies).sort().map((name) => {
|
||||
const file = join(ROOT, 'node_modules', name, 'package.json');
|
||||
if (!existsSync(file)) throw new Error(`${name} is not installed`);
|
||||
const meta = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package';
|
||||
return { name, version: meta.version, license, url: repoUrl(meta) };
|
||||
});
|
||||
|
||||
const out = {
|
||||
app: { name: pkg.name, license: pkg.license },
|
||||
libraries: [...libraries, ...VENDORED],
|
||||
};
|
||||
writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`);
|
||||
console.log(`${out.libraries.length} libraries`);
|
||||
@@ -0,0 +1,171 @@
|
||||
// Put an uploaded build on the public TestFlight link: wait until App Store
|
||||
// Connect has processed it, add it to the external beta group, set its
|
||||
// "What to Test" text and submit it for beta review. Builds only reach
|
||||
// external testers (and so the public link) once Apple approves them.
|
||||
//
|
||||
// node scripts/testflight-public.mjs <marketing version> <build number>
|
||||
//
|
||||
// Environment:
|
||||
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
|
||||
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.p8)
|
||||
// BUNDLE_ID default org.bulwarkmail.mobile
|
||||
// TESTFLIGHT_GROUP external group name (default "external")
|
||||
// WHATS_NEW "What to Test" text (default: link to the GitHub release)
|
||||
//
|
||||
// Every step can be repeated: a build already in the group, already described
|
||||
// or already submitted is left as it is, so a re-run picks up where the last
|
||||
// one stopped.
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const [version, buildNumber] = process.argv.slice(2);
|
||||
if (!version || !buildNumber) {
|
||||
console.error('usage: node scripts/testflight-public.mjs <marketing version> <build number>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const keyId = process.env.ASC_KEY_ID;
|
||||
const issuer = process.env.ASC_ISSUER_ID;
|
||||
if (!keyId || !issuer) {
|
||||
console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.');
|
||||
process.exit(2);
|
||||
}
|
||||
const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`);
|
||||
const privateKey = fs.readFileSync(keyPath, 'utf8');
|
||||
const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile';
|
||||
const groupName = process.env.TESTFLIGHT_GROUP || 'external';
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native';
|
||||
const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`;
|
||||
|
||||
// Processing usually takes 5-30 minutes; give up well inside the job timeout.
|
||||
const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const POLL_MS = 30 * 1000;
|
||||
|
||||
function token() {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' });
|
||||
const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' });
|
||||
const sig = crypto
|
||||
.sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
async function api(method, p, body) {
|
||||
const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
return { status: res.status, ok: res.ok, json };
|
||||
}
|
||||
|
||||
function fail(step, r) {
|
||||
const e = r.json?.errors?.[0];
|
||||
console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`);
|
||||
if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
console.error('An Apple agreement needs accepting in App Store Connect > Business.');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── The app and the group ──────────────────────────────────────
|
||||
const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`);
|
||||
if (!apps.ok) fail('Looking up the app', apps);
|
||||
const app = apps.json.data[0];
|
||||
if (!app) {
|
||||
console.error(`No app with bundle ID ${bundleId} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const groups = await api(
|
||||
'GET',
|
||||
`/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`,
|
||||
);
|
||||
if (!groups.ok) fail('Looking up the beta group', groups);
|
||||
const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName);
|
||||
if (!group) {
|
||||
console.error(`No external beta group named "${groupName}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Wait for the build ─────────────────────────────────────────
|
||||
const query =
|
||||
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
|
||||
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
|
||||
const started = Date.now();
|
||||
let build;
|
||||
for (;;) {
|
||||
const r = await api('GET', query);
|
||||
if (!r.ok) fail('Looking up the build', r);
|
||||
build = r.json.data[0];
|
||||
const state = build?.attributes.processingState;
|
||||
if (state === 'VALID') break;
|
||||
if (state === 'FAILED' || state === 'INVALID') {
|
||||
console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (Date.now() - started > PROCESSING_TIMEOUT_MS) {
|
||||
console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}) is processed.`);
|
||||
|
||||
// ── Add it to the group ────────────────────────────────────────
|
||||
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
|
||||
data: [{ type: 'builds', id: build.id }],
|
||||
});
|
||||
if (!add.ok) fail(`Adding the build to "${groupName}"`, add);
|
||||
console.log(`Added to the "${groupName}" group.`);
|
||||
|
||||
// ── "What to Test" ─────────────────────────────────────────────
|
||||
const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`);
|
||||
if (!locs.ok) fail('Reading the build notes', locs);
|
||||
const en = locs.json.data.find((l) => l.attributes.locale === 'en-US');
|
||||
const notes = en
|
||||
? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } },
|
||||
})
|
||||
: await api('POST', '/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew },
|
||||
relationships: { build: { data: { type: 'builds', id: build.id } } },
|
||||
},
|
||||
});
|
||||
if (!notes.ok) fail('Setting "What to Test"', notes);
|
||||
|
||||
// ── Beta review ────────────────────────────────────────────────
|
||||
const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
|
||||
if (!detail.ok) fail('Reading the build state', detail);
|
||||
const external = detail.json.data.attributes.externalBuildState;
|
||||
if (external === 'MISSING_EXPORT_COMPLIANCE') {
|
||||
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (external === 'READY_FOR_BETA_SUBMISSION') {
|
||||
const submit = await api('POST', '/v1/betaAppReviewSubmissions', {
|
||||
data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } },
|
||||
});
|
||||
if (!submit.ok) fail('Submitting for beta review', submit);
|
||||
console.log('Submitted for beta review.');
|
||||
} else {
|
||||
console.log(`Not submitted again: the build is ${external}.`);
|
||||
}
|
||||
|
||||
const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null;
|
||||
console.log(
|
||||
link
|
||||
? `Testers with ${link} get it once Apple approves the build.`
|
||||
: `The "${groupName}" group has no public link enabled.`,
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface BadgeProps {
|
||||
/** Sentence case ("Not on this server"); badges are never upper case. */
|
||||
children: string;
|
||||
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small status pill. The default is the webmail's outline badge: hairline
|
||||
* border, muted 12px text. The tinted variants keep the same size.
|
||||
*/
|
||||
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
paddingVertical: 1,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
text: { ...typography.caption, fontWeight: '500' },
|
||||
text: { fontSize: 12, lineHeight: 16 },
|
||||
|
||||
bg_default: { backgroundColor: c.surface },
|
||||
bg_primary: { backgroundColor: c.primaryBg },
|
||||
bg_default: { borderColor: c.border },
|
||||
bg_outline: { borderColor: c.border },
|
||||
bg_primary: { backgroundColor: c.accent },
|
||||
bg_destructive: { backgroundColor: c.errorBg },
|
||||
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||
bg_muted: { backgroundColor: c.muted },
|
||||
|
||||
text_default: { color: c.text },
|
||||
text_primary: { color: c.primary },
|
||||
text_default: { color: c.mutedForeground },
|
||||
text_outline: { color: c.mutedForeground },
|
||||
text_primary: { color: c.accentForeground },
|
||||
text_destructive: { color: c.error },
|
||||
text_outline: { color: c.text },
|
||||
text_muted: { color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
/**
|
||||
* The webmail's standard-theme buttons (components/ui/button.tsx):
|
||||
* - default: bg-primary text-primary-foreground
|
||||
* - secondary: bg-muted text-foreground
|
||||
* - outline: border, transparent
|
||||
* - ghost: no border or fill until pressed
|
||||
* - destructive: red fill, white text
|
||||
* - destructiveOutline: red border and text, for a reset in a settings row
|
||||
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
|
||||
*/
|
||||
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps {
|
||||
@@ -15,8 +25,13 @@ interface ButtonProps {
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
/** Needed for an icon-only button; a text button reads its text. */
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
// Extra touch area so every size reaches a 48 dp target.
|
||||
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
|
||||
|
||||
export default function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
@@ -26,10 +41,14 @@ export default function Button({
|
||||
loading = false,
|
||||
icon,
|
||||
style,
|
||||
accessibilityLabel,
|
||||
}: ButtonProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isDisabled = disabled || loading;
|
||||
const spinnerColor = variant === 'default'
|
||||
? c.primaryForeground
|
||||
: variant === 'destructive' ? '#ffffff' : c.text;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -43,17 +62,18 @@ export default function Button({
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
hitSlop={HIT_SLOP[size]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'default' ? c.primaryForeground : c.primary}
|
||||
/>
|
||||
<ActivityIndicator size="small" color={spinnerColor} />
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
{typeof children === 'string' ? (
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -69,30 +89,36 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md, gap: spacing.sm,
|
||||
borderRadius: radius.sm, gap: spacing.sm,
|
||||
},
|
||||
text: { ...typography.bodyMedium },
|
||||
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
|
||||
disabled: { opacity: 0.5 },
|
||||
textDisabled: { opacity: 0.5 },
|
||||
|
||||
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
|
||||
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
|
||||
// minHeight, so a larger system font grows the button instead of clipping.
|
||||
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
|
||||
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
|
||||
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
|
||||
|
||||
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||
bg_ghost: { backgroundColor: 'transparent' },
|
||||
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||
bg_default: { backgroundColor: c.primary },
|
||||
bg_secondary: { backgroundColor: c.muted },
|
||||
bg_ghost: { backgroundColor: 'transparent' },
|
||||
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||
bg_destructive: { backgroundColor: c.error },
|
||||
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
|
||||
|
||||
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||
pressed_ghost: { backgroundColor: c.accent },
|
||||
pressed_outline: { backgroundColor: c.accent },
|
||||
pressed_destructive: { opacity: 0.9 },
|
||||
pressed_default: { opacity: 0.9 },
|
||||
pressed_secondary: { opacity: 0.8 },
|
||||
pressed_ghost: { backgroundColor: c.muted },
|
||||
pressed_outline: { backgroundColor: c.muted },
|
||||
pressed_destructive: { opacity: 0.9 },
|
||||
pressed_destructiveOutline: { backgroundColor: c.errorBg },
|
||||
|
||||
text_default: { color: c.primaryForeground },
|
||||
text_ghost: { color: c.text },
|
||||
text_outline: { color: c.text },
|
||||
text_destructive: { color: c.errorForeground },
|
||||
text_default: { color: c.primaryForeground },
|
||||
text_secondary: { color: c.text },
|
||||
text_ghost: { color: c.text },
|
||||
text_outline: { color: c.text },
|
||||
text_destructive: { color: '#ffffff' },
|
||||
text_destructiveOutline: { color: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
|
||||
import { AlertTriangle } from 'lucide-react-native';
|
||||
import { AlertTriangle } from './icons';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
interface DialogInput {
|
||||
value: string;
|
||||
@@ -55,7 +56,7 @@ export default function Dialog({
|
||||
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
|
||||
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.dialog}>
|
||||
<View style={styles.content}>
|
||||
@@ -71,7 +72,7 @@ export default function Dialog({
|
||||
value={input.value}
|
||||
onChangeText={input.onChangeText}
|
||||
placeholder={input.placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
autoFocus
|
||||
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
|
||||
accessibilityLabel={input.placeholder ?? title}
|
||||
@@ -142,15 +143,17 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
// The shared Input look: 40 dp, transparent, radius 6.
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 0,
|
||||
minHeight: 40,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
footer: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import { Image as ImageIcon, ShieldCheck } from './icons';
|
||||
import type { Email } from '../api/types';
|
||||
import { extractCidRefs } from '../lib/email-html';
|
||||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
|
||||
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted } from '../lib/trusted-senders';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -527,6 +528,8 @@ export default function EmailBodyView({
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const hasContacts = useHasContacts();
|
||||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
@@ -540,8 +543,8 @@ export default function EmailBodyView({
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
@@ -561,7 +564,7 @@ export default function EmailBodyView({
|
||||
const text = picked.text;
|
||||
const trusted = isSenderContentTrusted(senderEmail, {
|
||||
isLocallyTrusted: isSenderTrusted,
|
||||
syncEnabled: trustedSendersAddressBook,
|
||||
syncEnabled: syncTrustedSenders,
|
||||
trustedBookEmails: trustedSenderEmails,
|
||||
});
|
||||
|
||||
@@ -721,12 +724,11 @@ export default function EmailBodyView({
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
// Keep the local allow-list for instant effect, and file the sender in
|
||||
// the server-side "Trusted Senders" address book (synced across
|
||||
// devices) unless the user turned that off.
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook) {
|
||||
if (syncTrustedSenders) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
setAllowOnce(true);
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Mail } from 'lucide-react-native';
|
||||
import { X, Check, Mail } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Identity } from '../api/types';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
|
||||
interface IdentitySheetProps {
|
||||
visible: boolean;
|
||||
@@ -25,6 +27,8 @@ export function IdentitySheet({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
// Centred on a wide window, clear of side insets in landscape.
|
||||
const sheetInsets = useSheetInsets();
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
const { t } = useLocaleStore();
|
||||
@@ -44,14 +48,14 @@ export function IdentitySheet({
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
@@ -60,7 +64,7 @@ export function IdentitySheet({
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
|
||||
@@ -9,16 +9,17 @@ interface InputProps extends TextInputProps {
|
||||
containerStyle?: ViewStyle;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
/** React 19 passes `ref` as a prop; it reaches the TextInput (focus chaining). */
|
||||
ref?: React.Ref<RNTextInput>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches webmail input.tsx:
|
||||
* - h-10 w-full rounded-md border border-input bg-background
|
||||
* - px-3 py-2 text-sm text-foreground
|
||||
* - placeholder:text-muted-foreground
|
||||
* - hover:border-muted-foreground
|
||||
* - focus:ring-2 ring-ring border-ring
|
||||
* - disabled:opacity-50
|
||||
* Matches webmail input.tsx in the standard theme:
|
||||
* - h-10 rounded-md border border-input, transparent background
|
||||
* - px-3 text-sm text-foreground, placeholder in muted-foreground
|
||||
* - focus: border-ring
|
||||
* - disabled: opacity-50
|
||||
* - label above in text-sm font-medium text-foreground
|
||||
*/
|
||||
export default function Input({
|
||||
label,
|
||||
@@ -27,6 +28,7 @@ export default function Input({
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
style,
|
||||
ref,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const c = useColors();
|
||||
@@ -44,9 +46,10 @@ export default function Input({
|
||||
]}>
|
||||
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
|
||||
<RNTextInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={[styles.input, style]}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
|
||||
/>
|
||||
@@ -61,22 +64,22 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
label: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
color: c.text,
|
||||
marginBottom: 6,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.inputHeight, // h-10 = 40px
|
||||
minHeight: componentSizes.inputHeight, // h-10 = 40px; grows with a larger system font
|
||||
borderWidth: 1,
|
||||
borderColor: c.border, // border-input
|
||||
borderRadius: radius.sm, // rounded-md = 6px
|
||||
backgroundColor: c.background, // bg-background
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.md, // px-3 = 12px
|
||||
},
|
||||
// Colour only, so focusing doesn't shift the text by a pixel.
|
||||
inputFocused: {
|
||||
borderColor: c.borderFocus, // border-ring (blue-500)
|
||||
borderWidth: 2,
|
||||
borderColor: c.borderFocus,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: c.error,
|
||||
|
||||