├── .github └── workflows │ └── checks.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 14.txt │ ├── 15.txt │ ├── 18.txt │ ├── 20.txt │ ├── 21.txt │ ├── 22.txt │ ├── 23.txt │ ├── 24.txt │ ├── 25.txt │ ├── 26.txt │ ├── 27.txt │ ├── 28.txt │ ├── 29.txt │ ├── 30.txt │ ├── 31.txt │ ├── 32.txt │ ├── 33.txt │ ├── 34.txt │ ├── 35.txt │ ├── 36.txt │ ├── 37.txt │ ├── 38.txt │ ├── 39.txt │ ├── 40.txt │ ├── 41.txt │ └── 42.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── screenshot-01.png │ │ ├── screenshot-02.png │ │ ├── screenshot-03.png │ │ ├── screenshot-04.png │ │ └── screenshot-05.png │ └── short_description.txt ├── scripts ├── build-podman-exec.sh ├── build-podman.sh └── podman-do.sh ├── you-have-mail-android ├── .gitignore ├── README.md ├── app │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── java │ │ │ └── dev │ │ │ └── lbeernaert │ │ │ └── youhavemail │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── ic_launcher-playstore.png │ │ ├── java │ │ │ └── dev │ │ │ │ └── lbeernaert │ │ │ │ └── youhavemail │ │ │ │ ├── .gitignore │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── OpenAppActivity.kt │ │ │ │ ├── app │ │ │ │ ├── ActionReceivers.kt │ │ │ │ ├── ActionWorker.kt │ │ │ │ ├── Backends.kt │ │ │ │ ├── LoginSequence.kt │ │ │ │ ├── Logs.kt │ │ │ │ ├── Notifications.kt │ │ │ │ ├── PollWorker.kt │ │ │ │ ├── StartReceiver.kt │ │ │ │ ├── State.kt │ │ │ │ └── YhmInstance.kt │ │ │ │ ├── components │ │ │ │ ├── ActionButton.kt │ │ │ │ ├── AsyncScreen.kt │ │ │ │ ├── BackgroundTask.kt │ │ │ │ └── PasswordField.kt │ │ │ │ ├── screens │ │ │ │ ├── AccountInfo.kt │ │ │ │ ├── BackendSelection.kt │ │ │ │ ├── Login.kt │ │ │ │ ├── Main.kt │ │ │ │ ├── Nav.kt │ │ │ │ ├── ProtonCaptcha.kt │ │ │ │ ├── Proxy.kt │ │ │ │ ├── Routes.kt │ │ │ │ ├── Settings.kt │ │ │ │ └── Totp.kt │ │ │ │ └── ui │ │ │ │ ├── Autofill.kt │ │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ ├── ic_stat_alert.png │ │ │ ├── ic_stat_err.png │ │ │ └── ic_stat_sync.png │ │ │ ├── drawable-mdpi │ │ │ ├── ic_stat_alert.png │ │ │ ├── ic_stat_err.png │ │ │ └── ic_stat_sync.png │ │ │ ├── drawable-xhdpi │ │ │ ├── ic_stat_alert.png │ │ │ ├── ic_stat_err.png │ │ │ └── ic_stat_sync.png │ │ │ ├── drawable-xxhdpi │ │ │ ├── ic_stat_alert.png │ │ │ ├── ic_stat_err.png │ │ │ └── ic_stat_sync.png │ │ │ ├── drawable-xxxhdpi │ │ │ ├── ic_stat_alert.png │ │ │ ├── ic_stat_err.png │ │ │ └── ic_stat_sync.png │ │ │ ├── drawable │ │ │ └── ic_base.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ │ └── xml │ │ │ ├── backup_rules.xml │ │ │ └── data_extraction_rules.xml │ │ └── test │ │ └── java │ │ └── dev │ │ └── lbeernaert │ │ └── youhavemail │ │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── icon_inkscape.svg └── settings.gradle └── you-have-mail-mobile ├── Cargo.lock ├── Cargo.toml ├── rust-toolchain.toml ├── src ├── account.rs ├── android.rs ├── backend │ ├── mod.rs │ └── proton.rs ├── events.rs ├── lib.rs ├── logging.rs ├── proxy.rs ├── watcher.rs └── yhm.rs ├── uniffi-bindgen └── uniffi-bindgen.rs └── uniffi.toml /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Code Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [ opened, synchronize, reopened ] 9 | branches: 10 | - main 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | test: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name : Get sources 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup Go 1.19 24 | uses: actions/setup-go@v3 25 | with: 26 | go-version: '1.20' 27 | 28 | - name: Setup Rust 29 | uses: dtolnay/rust-toolchain@1.83.0 30 | 31 | - name: Run Tests 32 | working-directory: you-have-mail-mobile 33 | run: cargo build 34 | 35 | 36 | fmt: 37 | name: Rustmft 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name : Get sources 41 | uses: actions/checkout@v3 42 | 43 | - name: Setup Rust 44 | uses: dtolnay/rust-toolchain@stable 45 | 46 | - name: Check formatting 47 | working-directory: you-have-mail-mobile 48 | run: cargo fmt --check 49 | 50 | clippy: 51 | name: Clippy 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name : Get sources 55 | uses: actions/checkout@v3 56 | 57 | - name: Setup Rust 58 | uses: dtolnay/rust-toolchain@stable 59 | 60 | - name: Check formatting 61 | working-directory: you-have-mail-mobile 62 | run: cargo clippy -- -D warnings 63 | 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | /artifacts 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | ENV ANDROID_BUILD_TOOLS_VERSION 33.0.1 4 | ENV ANDROID_HOME /opt/android/sdk 5 | ENV ANDROID_NDK_DIR /opt/android/ndk 6 | ENV ANDROID_NDK_HOME $ANDROID_HOME/ndk 7 | ENV ANDROID_VERSION 34 8 | ENV PATH $PATH:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/bin 9 | ENV ANDROID_NDK_VERSION "25.2.9519653" 10 | ENV RUST_VERSION="1.85.0" 11 | 12 | 13 | RUN apt-get update && \ 14 | apt-get install -y --no-install-recommends unzip curl sdkmanager && \ 15 | mkdir -p $ANDROID_HOME $ANDROID_NDK 16 | 17 | # Install build deps 18 | RUN apt-get install -y automake clang gcc-multilib libclang-dev \ 19 | libtool make pkg-config python-is-python3 \ 20 | openjdk-17-jdk-headless 21 | 22 | # Install SDK 23 | RUN yes | sdkmanager --licenses --sdk_root=$ANDROID_HOME && \ 24 | sdkmanager --sdk_root=$ANDROID_HOME \ 25 | "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \ 26 | "platforms;android-${ANDROID_VERSION}" \ 27 | "platform-tools" \ 28 | "ndk;$ANDROID_NDK_VERSION" 29 | 30 | # Install rust 31 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "$RUST_VERSION" 32 | 33 | # Install git 34 | RUN apt-get install -y git 35 | 36 | # Cleanup 37 | RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ 38 | apt-get autoremove -y && \ 39 | apt-get clean 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # You Have Mail 2 | 3 | Small application to notify you when you receive an email in your email account. This may be useful for cases where 4 | you only wish to be notified when your email account has a new message and the default notification mechanism 5 | does not work (e.g: Android without Google Play Services) or do not wish to have the web interface/email client open at 6 | all times. 7 | 8 | If you want these features in CLI package, please see [You Have Mail CLI](https://github.com/LeanderBB/you-have-mail-cli). 9 | 10 | ## Supported Backends 11 | 12 | The application structure has been made backend agnostics, so it should be possible to add different providers in the 13 | future. Currently, the following email providers are supported: 14 | 15 | * [Proton Mail](https://mail.proton.me) 16 | 17 | ## Structure 18 | 19 | This repository is split into the following projects: 20 | 21 | * [you-have-mail-mobile](you-have-mail-mobile): Shared code for mobile bindings 22 | * [you-have-mail-android](you-have-mail-android): Android Application 23 | 24 | ## Download 25 | 26 | Please only download the latest stable releases from: 27 | 28 | * Github: [Releases](https://github.com/LeanderBB/you-have-mail/releases) 29 | * F-Droid: [Link](https://f-droid.org/packages/dev.lbeernaert.youhavemail/) 30 | 31 | [Get it on F-Droid](https://f-droid.org/packages/dev.lbeernaert.youhavemail/) 32 | 33 | 34 | ## Donations 35 | 36 | If you wish to donate to this project, consider donating to the 37 | [GrapheneOS](https://grapheneos.org/donate) project instead :). 38 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | * Only use Inbox Style notification when more than 1 message is available. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/15.txt: -------------------------------------------------------------------------------- 1 | * Add fastlane metadata for FDroid inclusion 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/18.txt: -------------------------------------------------------------------------------- 1 | * Feat: Only send notifications for messages that have not 2 | been seen on other clients. 3 | * Feat: Remove Rust async runtime in favor of blocking code. 4 | Might have some benefits for battery life. 5 | * Fix: When no network is available release wake lock to allow 6 | the CPU to go into idle. Helps with battery life. 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/20.txt: -------------------------------------------------------------------------------- 1 | * Changes to ensure reproducible builds between fdroid and github 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/21.txt: -------------------------------------------------------------------------------- 1 | * Auto start foreground service when Phone reboots 2 | * Clarify that Proxy settings are Optional 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/22.txt: -------------------------------------------------------------------------------- 1 | * Feature: Handle Proton Captcha verification 2 | * Fix: Do not report Logout notification when user is logged out due to user action 3 | * Fix: Distinguish request parser error from actual network error 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/23.txt: -------------------------------------------------------------------------------- 1 | * Fix deadlock on proton account auto session refresh 2 | * Remove Proton Other Backend, no longer required with the introduction of Captcha support 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/24.txt: -------------------------------------------------------------------------------- 1 | * Fix login for Proton accounts which have both TOTP and FIDO2 as 2FA 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/25.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | * Log files: Application can now record and share logs for debugging 3 | * Password field visibility toggle 4 | * Monochrome Icon (Android 13+) 5 | 6 | # Fixes 7 | * Improve app state management 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/26.txt: -------------------------------------------------------------------------------- 1 | # Fixes 2 | * Fix account ordering after adding new account 3 | 4 | # Misc 5 | * Tweak log output 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/27.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | * Improve battery life by using Android Work Manager to drive the polling of accounts. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/28.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | * Add support for new mail notification in custom folders which have notifications enabled for 4 | Proton Mail accounts. 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/29.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | * Add support for 32Bit Arm CPUs 4 | * Add Refresh button to poll accounts now rather than wait for the next scheduled poll. 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/30.txt: -------------------------------------------------------------------------------- 1 | # Fixes 2 | 3 | * Improve handling of session refreshes when there is no network. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/31.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | * Auto fill support (Thanks @thgoebel) 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/32.txt: -------------------------------------------------------------------------------- 1 | # Notice 2 | 3 | * All accounts will be in logged out state after migration 4 | to new storage format. 5 | 6 | # Changed 7 | 8 | * Removed background service and notification 9 | * Polling is now preformed by WorkManager 10 | * Account state is persisted to disk between OS reboots 11 | * Update dependencies and deprecated APIs 12 | 13 | # Fixes 14 | 15 | * Startup crash when starting service from a worker 16 | * Fix proxy screen not remembering values 17 | * Https is not a valid proxy protocol 18 | 19 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/33.txt: -------------------------------------------------------------------------------- 1 | # Notice 2 | 3 | * All accounts will be in logged out state after migration 4 | to new storage format. 5 | 6 | # Changed 7 | 8 | * Removed background service and notification 9 | * Polling is now preformed by WorkManager 10 | * Account state is persisted to disk between OS reboots 11 | * Update dependencies and deprecated APIs 12 | 13 | # Fixes 14 | 15 | * Startup crash when starting service from a worker 16 | * Fix proxy screen not remembering values 17 | * Https is not a valid proxy protocol 18 | 19 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/34.txt: -------------------------------------------------------------------------------- 1 | # Fixed 2 | 3 | * Register workers when boot signal is received 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/35.txt: -------------------------------------------------------------------------------- 1 | # Fixed 2 | 3 | * Some notifications not being delivered 4 | * Do not cancel work on startup if no change has been made to poll interval 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/36.txt: -------------------------------------------------------------------------------- 1 | # Fixed 2 | 3 | - Fixed poll not triggering for intervals less than 15 min. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | # Changed 2 | 3 | * Dependency updates 4 | * Improve account list update 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/38.txt: -------------------------------------------------------------------------------- 1 | # Changed 2 | 3 | * Remember UI state when switching between landscape and veritcal views (Thanks @pangratt12345) 4 | * Add more poll intervals (Thanks @pangratt12345) 5 | * Improve poll interval drop down menu (Thanks @pangratt12345) 6 | * Check email and password input before sending request to server (Thanks @pangratt12345) 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/39.txt: -------------------------------------------------------------------------------- 1 | # Fix 2 | 3 | Reproducible builds for fdroid. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/40.txt: -------------------------------------------------------------------------------- 1 | # Added 2 | 3 | * Received messages can now be marked read, moved to trash or spam from the notification 4 | 5 | # Changed 6 | 7 | * Create individual notifications for each message and group them by account email. 8 | 9 | # Fixed 10 | 11 | * Going back in the activity history should now correctly return to the last place before opening 12 | the mail application. 13 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/41.txt: -------------------------------------------------------------------------------- 1 | # Fix 2 | 3 | - Persist notification ids to avoid notification mix up. 4 | - Reduce number of database connections. 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/42.txt: -------------------------------------------------------------------------------- 1 | # Changed 2 | 3 | - Replace go-srp with proton-srp 4 | - Update dependencies 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Small application to notify you when you receive an email in your email account. 2 | This may be useful for cases where you only wish to be notified when your email 3 | account has a new message and the default notification mechanism does not work 4 | (e.g: Android without Google Play Services) or do not wish to have the web 5 | interface/email client open at all times. 6 | 7 | Supported Backends 8 | 9 | The application structure has been made backend agnostics, so it should be possible to add 10 | different providers in the future. Currently, the following email providers are supported: 11 | 12 | * Proton Mail 13 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-01.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-02.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-03.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-04.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot-05.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Email Notification without Google Play Services 2 | -------------------------------------------------------------------------------- /scripts/build-podman-exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | OUTPUT="/opt/artifacts/app-signed.apk" 6 | ALIGNED="/tmp/app-aligned.apk" 7 | 8 | source ~/.cargo/env 9 | 10 | echo "Cloning repo" 11 | git clone "https://github.com/LeanderBB/you-have-mail.git" /opt/project 12 | 13 | cd /opt/project/you-have-mail-android 14 | 15 | # Build Project 16 | ./gradlew --no-daemon assembleRelease 17 | 18 | # Align zip 19 | echo "Aligning apk" 20 | $ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS_VERSION/zipalign -v 4 \ 21 | app/build/outputs/apk/release/app-release-unsigned.apk $ALIGNED 22 | 23 | # Sign apk 24 | echo "Signing apk" 25 | echo $KEY_STORE_PWD | $ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS_VERSION/apksigner sign \ 26 | --ks /opt/keystore \ 27 | --in $ALIGNED \ 28 | --out $OUTPUT 29 | 30 | # Verify 31 | echo "Verifying apk" 32 | $ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS_VERSION/apksigner verify $OUTPUT 33 | 34 | -------------------------------------------------------------------------------- /scripts/build-podman.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | # 3 | set -eou pipefail 4 | 5 | mkdir -p artifacts 6 | 7 | scripts/podman-do.sh "/opt/scripts/build-podman-exec.sh" 8 | 9 | -------------------------------------------------------------------------------- /scripts/podman-do.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | # 3 | set -eou pipefail 4 | 5 | podman run --rm \ 6 | --volume $PWD/scripts:/opt/scripts:O \ 7 | --volume $PWD/artifacts:/opt/artifacts:Z \ 8 | --secret android_keystore,type=mount,target=/opt/keystore \ 9 | --secret android_key_pwd,type=env,target=KEY_STORE_PWD \ 10 | -i -t yhm $@ 11 | 12 | -------------------------------------------------------------------------------- /you-have-mail-android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /you-have-mail-android/README.md: -------------------------------------------------------------------------------- 1 | # You Have Mail Android 2 | 3 | Here you can find the android application that uses the You Have Mail shared service code. 4 | 5 | ## Architecture 6 | 7 | This app has been specifically designed to work in cases where Google Play services are not available. The App launches 8 | a Foreground Service that polls the accounts every 15 seconds. Once an account reports a new message has arrived it 9 | will create a notification. 10 | 11 | When the App knows about which backend maps to which Android application, it will try to launch that app if the user 12 | clicks on the application. 13 | 14 | ## Compatibility 15 | 16 | The Application is only available for x86_64 and Aarch64 and is has API 29 as minimum requirements. It has been tested 17 | on a Pixel 3a running the last compatible version of [Graphene OS](https://grapheneos.org/). 18 | 19 | ## Security 20 | 21 | Session tokens are stored using the `EncryptedSharedPreferences` API. 22 | 23 | ## Building and Installing using Android Studio 24 | 25 | This application is built in Android Studio. It uses Rust programming language. 26 | In order to build and run this application in Android Studio install Rust in your environment. 27 | Below commands are for Linux Debian/Ubuntu: 28 | ``` 29 | sudo apt-get install -y python-is-python3 golang-go; 30 | # Install Rust using official method 31 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 32 | rustup default stable; 33 | rustup target add armv7-linux-androideabi; 34 | rustup target add aarch64-linux-android; 35 | rustup target add x86_64-linux-android; 36 | ``` 37 | Alternatively use suitable docker image with Rust preinstalled instead of manual installation above. 38 | 39 | -------------------------------------------------------------------------------- /you-have-mail-android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /you-have-mail-android/app/build.gradle: -------------------------------------------------------------------------------- 1 | // NB: Android Studio can't find the imports; this does not affect the 2 | // actual build since Gradle can find them just fine. 3 | import com.android.tools.profgen.ArtProfileKt 4 | import com.android.tools.profgen.ArtProfileSerializer 5 | import com.android.tools.profgen.DexFile 6 | import org.gradle.internal.os.OperatingSystem 7 | 8 | plugins { 9 | id 'com.android.application' 10 | id 'org.jetbrains.kotlin.android' 11 | id 'kotlin-parcelize' 12 | id "org.mozilla.rust-android-gradle.rust-android" version "0.9.5" 13 | } 14 | 15 | android { 16 | namespace 'dev.lbeernaert.youhavemail' 17 | compileSdk 34 18 | 19 | defaultConfig { 20 | applicationId "dev.lbeernaert.youhavemail" 21 | minSdk 26 22 | targetSdk 33 23 | versionCode 42 24 | versionName "0.20.0" 25 | 26 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 27 | vectorDrawables { 28 | useSupportLibrary true 29 | } 30 | } 31 | 32 | buildTypes { 33 | release { 34 | minifyEnabled false 35 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 36 | } 37 | } 38 | compileOptions { 39 | sourceCompatibility JavaVersion.VERSION_11 40 | targetCompatibility JavaVersion.VERSION_11 41 | } 42 | kotlinOptions { 43 | jvmTarget = '11' 44 | } 45 | buildFeatures { 46 | compose true 47 | } 48 | composeOptions { 49 | kotlinCompilerExtensionVersion '1.4.0' 50 | } 51 | packagingOptions { 52 | resources { 53 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 54 | } 55 | } 56 | ndkVersion "25.2.9519653" 57 | 58 | defaultConfig { 59 | ndk { 60 | //noinspection ChromeOsAbiSupport 61 | abiFilters 'arm64-v8a', 'x86_64', 'armeabi-v7a' 62 | } 63 | } 64 | 65 | // https://gitlab.com/fdroid/fdroiddata/-/issues/3330#note_2189915200 66 | dependenciesInfo { 67 | // Disables dependency metadata when building APKs. 68 | includeInApk = false 69 | // Disables dependency metadata when building Android App Bundles. 70 | includeInBundle = false 71 | } 72 | } 73 | 74 | dependencies { 75 | // WorkManager dependency 76 | implementation "androidx.work:work-runtime-ktx:$versions_work" 77 | 78 | implementation 'androidx.core:core-ktx:1.13.1' 79 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4' 80 | implementation 'androidx.activity:activity-compose:1.9.1' 81 | implementation "androidx.compose.ui:ui:$compose_ui_version" 82 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" 83 | implementation 'androidx.compose.material:material:1.6.8' 84 | implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' 85 | implementation 'androidx.appcompat:appcompat:1.7.0' 86 | implementation 'com.google.android.material:material:1.12.0' 87 | testImplementation 'junit:junit:4.13.2' 88 | androidTestImplementation 'androidx.test.ext:junit:1.2.1' 89 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' 90 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" 91 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" 92 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" 93 | implementation 'androidx.navigation:navigation-compose:2.8.9' 94 | implementation "androidx.compose.material:material-icons-extended:$compose_ui_version" 95 | 96 | // Required for JNI 97 | implementation 'net.java.dev.jna:jna:5.14.0@aar' 98 | } 99 | 100 | apply plugin: 'org.mozilla.rust-android-gradle.rust-android' 101 | 102 | cargo { 103 | module = "../../you-have-mail-mobile" 104 | libname = "youhavemail" 105 | targets = ["x86_64", "arm64", "arm"] 106 | targetIncludes = ["libyouhavemail.so"] 107 | targetDirectory = "/tmp/build-yhm" 108 | profile = "release" 109 | prebuiltToolchains = true 110 | rustupChannel = "1.85.0" 111 | apiLevel = 26 112 | 113 | // Ensure paths are consistent for reproducible builds. 114 | exec { spec, _ -> 115 | var home = "$System.env.HOME" 116 | var pwd = "$System.env.PWD" 117 | spec.environment("RUSTFLAGS", "--remap-path-prefix=${home}=/remap --remap-path-prefix=${pwd}=/remap --remap-path-prefix=${pwd}/you-have-mail=/remap") 118 | spec.environment("CARGO_TARGET_DIR", "/tmp/build-yhm") 119 | } 120 | 121 | extraCargoBuildArguments = ['--locked'] 122 | } 123 | 124 | task bindingBuild(type: Exec) { 125 | workingDir "../../you-have-mail-mobile" 126 | commandLine "cargo", "build" 127 | } 128 | 129 | var ext = "so" 130 | if (OperatingSystem.current().isMacOsX()) { 131 | ext = "dylib" 132 | } 133 | 134 | task genBindings(type: Exec) { 135 | workingDir "../../you-have-mail-mobile" 136 | commandLine "cargo", "run", "--bin", "uniffi-bindgen", "--", "generate", "--library", 137 | "target/debug/libyouhavemail.$ext", 138 | "--language", "kotlin", "--config", "uniffi.toml", 139 | "--out-dir", "../you-have-mail-android/app/src/main/java" 140 | } 141 | 142 | tasks.whenTaskAdded { task -> 143 | // Require cargo to be run before copying native libraries. 144 | if ((task.name == 'mergeDebugJniLibFolders' || task.name == 'mergeReleaseJniLibFolders')) { 145 | task.dependsOn 'cargoBuild' 146 | } 147 | 148 | if (task.name == 'preBuild') { 149 | task.dependsOn genBindings 150 | } 151 | 152 | if (task.name == 'genBindings') { 153 | task.dependsOn 'cargoBuild' 154 | } 155 | 156 | if (task.name == 'compileDebugKotlin') { 157 | task.dependsOn 'genBindings' 158 | } 159 | } 160 | 161 | afterEvaluate { 162 | 163 | // The `cargoBuild` task isn't available until after evaluation. 164 | android.applicationVariants.all { variant -> 165 | def productFlavor = "" 166 | variant.productFlavors.each { 167 | productFlavor += "${it.name.capitalize()}" 168 | } 169 | def buildType = "${variant.buildType.name.capitalize()}" 170 | tasks["generate${productFlavor}${buildType}Assets"].dependsOn(tasks["cargoBuild"]) 171 | } 172 | } 173 | 174 | // Sort .profm files for reproducible builds. Taken from 175 | // https://gist.github.com/obfusk/eb82a810ed6aad266dab19977b18cee6 176 | project.afterEvaluate { 177 | tasks.each { task -> 178 | if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) { 179 | task.doLast { 180 | outputs.files.each { file -> 181 | if (file.name.endsWith(".profm")) { 182 | println("Sorting ${file} ...") 183 | def version = ArtProfileSerializer.valueOf("METADATA_0_0_2") 184 | def profile = ArtProfileKt.ArtProfile(file) 185 | def keys = new ArrayList(profile.profileData.keySet()) 186 | def sortedData = new LinkedHashMap() 187 | Collections.sort keys, new DexFile.Companion() 188 | keys.each { key -> sortedData[key] = profile.profileData[key] } 189 | new FileOutputStream(file).with { 190 | write(version.magicBytes$profgen) 191 | write(version.versionBytes$profgen) 192 | version.write$profgen(it, sortedData, "") 193 | } 194 | } 195 | } 196 | } 197 | } 198 | } 199 | } 200 | 201 | afterEvaluate { 202 | genBindings.dependsOn(bindingBuild, cargoBuild) 203 | android.applicationVariants.all { variant -> 204 | variant.javaCompiler.dependsOn(genBindings) 205 | } 206 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /you-have-mail-android/app/src/androidTest/java/dev/lbeernaert/youhavemail/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("dev.lbeernaert.youhavemail", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 48 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/.gitignore: -------------------------------------------------------------------------------- 1 | youhavemail.kt 2 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/OpenAppActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail 2 | 3 | import android.app.Activity 4 | import android.app.PendingIntent 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.os.Bundle 8 | import android.util.Log 9 | import android.widget.Toast 10 | import dev.lbeernaert.youhavemail.app.NOTIFICATION_LOG_TAG 11 | import dev.lbeernaert.youhavemail.app.NOTIFICATION_STATE 12 | import dev.lbeernaert.youhavemail.app.NotificationActionClicked 13 | import dev.lbeernaert.youhavemail.app.NotificationIntentAppNameKey 14 | import dev.lbeernaert.youhavemail.app.NotificationIntentBackendKey 15 | import dev.lbeernaert.youhavemail.app.NotificationIntentEmailKey 16 | import dev.lbeernaert.youhavemail.app.getAppNameForBackend 17 | import dev.lbeernaert.youhavemail.app.getOrCreateEncryptionKey 18 | import dev.lbeernaert.youhavemail.app.newRequestCode 19 | 20 | class OpenAppActivity : Activity() { 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | val action = intent.action ?: return 24 | 25 | finish() 26 | 27 | if (action == NotificationActionClicked) { 28 | val backend = intent.getStringExtra(NotificationIntentBackendKey)!! 29 | val email = intent.getStringExtra(NotificationIntentEmailKey)!! 30 | val appName = intent.getStringExtra(NotificationIntentAppNameKey)!! 31 | 32 | 33 | // Launch the app for this backend 34 | Log.d(activityLogTag, "Receive click request for '$email' backend='$backend'") 35 | try { 36 | // Dismiss group notifications and children as they are not auto cleared 37 | // in this case. 38 | NOTIFICATION_STATE.dismissGroupNotification(this, email, true) 39 | 40 | Log.d(activityLogTag, "Attempting to launch $appName") 41 | val appIntent = 42 | packageManager.getLaunchIntentForPackage(appName) 43 | if (appIntent != null) { 44 | appIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 45 | appIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 46 | appIntent.addFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) 47 | appIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) 48 | startActivity(appIntent) 49 | } else { 50 | Log.e(activityLogTag, "Could not find package $appName") 51 | Toast.makeText(this, "Could not find package $appName", Toast.LENGTH_LONG) 52 | .show() 53 | } 54 | } catch (e: Exception) { 55 | Log.e(activityLogTag, "Failed to launch $appName for backend $backend: $e") 56 | yhmLogError("Failed to launch $appName for backend $backend: $e") 57 | Toast.makeText( 58 | this, 59 | "Failed to launch $appName for backed $backend", 60 | Toast.LENGTH_LONG 61 | ) 62 | .show() 63 | } 64 | } 65 | } 66 | 67 | companion object { 68 | fun newIntent( 69 | context: Context, 70 | email: String, 71 | backend: String 72 | ): PendingIntent? { 73 | val appName = getAppNameForBackend(backend) 74 | return if (appName != null) { 75 | Intent(context, OpenAppActivity::class.java).let { intent -> 76 | intent.action = NotificationActionClicked 77 | intent.putExtra( 78 | NotificationIntentEmailKey, email 79 | ) 80 | intent.putExtra( 81 | NotificationIntentBackendKey, backend 82 | ) 83 | intent.putExtra( 84 | NotificationIntentAppNameKey, 85 | appName 86 | ) 87 | intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) 88 | intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 89 | PendingIntent.getActivity( 90 | context, 91 | newRequestCode(), 92 | intent, 93 | PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT 94 | ) 95 | } 96 | } else { 97 | Log.d( 98 | NOTIFICATION_LOG_TAG, 99 | "No app found for backed '$backend'. No notification action" 100 | ) 101 | null 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/ActionReceivers.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import dev.lbeernaert.youhavemail.R 8 | import dev.lbeernaert.youhavemail.Yhm 9 | import dev.lbeernaert.youhavemail.yhmLogError 10 | import dev.lbeernaert.youhavemail.yhmLogInfo 11 | 12 | 13 | private const val NotificationIDKey = "NotificationID" 14 | private const val TAG = "Receivers" 15 | 16 | /** 17 | * Base class receiver for actions which can be taken on a notification. 18 | */ 19 | open class ActionReceiver( 20 | private val name: String, 21 | private val descriptionSuccess: Int, 22 | private val descriptionFail: Int 23 | ) : 24 | BroadcastReceiver() { 25 | 26 | override fun onReceive(context: Context, intent: Intent) { 27 | val email = intent.getStringExtra(NotificationIntentEmailKey) 28 | val action = intent.getStringExtra(NotificationIntentActionKey) 29 | val backend = intent.getStringExtra(NotificationIntentBackendKey) 30 | if (email == null || action == null || backend == null) { 31 | yhmLogError("Received $name broadcast, but email, backend or action was not set") 32 | Log.e(TAG, "Received $name broadcast, but email, backend or action was not set") 33 | return 34 | } 35 | val notificationID = intent.getIntExtra(NotificationIDKey, 0) 36 | if (notificationID != 0) { 37 | NOTIFICATION_STATE.dismissNotification(context, email, backend, notificationID) 38 | } 39 | yhmLogInfo("Received $name broadcast for $email") 40 | 41 | try { 42 | ActionWorker.queue(context, email, action, descriptionSuccess, descriptionFail) 43 | } catch (e: Exception) { 44 | yhmLogError("Failed to queue action for $email: $e") 45 | createAndDisplayServiceErrorNotification( 46 | context, 47 | "Failed to queue action for $email: $e" 48 | ) 49 | } 50 | } 51 | 52 | companion object { 53 | fun fillIntentArgs( 54 | intent: Intent, 55 | notificationID: Int, 56 | email: String, 57 | backend: String, 58 | action: String, 59 | ): Intent { 60 | return intent.putExtra(NotificationIntentEmailKey, email) 61 | .putExtra(NotificationIntentActionKey, action) 62 | .putExtra(NotificationIDKey, notificationID) 63 | .putExtra(NotificationIntentBackendKey, backend) 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Broadcast when the user clicks the `Mark Read` notification action. 70 | */ 71 | class MarkReadReceiver : ActionReceiver( 72 | name = "MarkRead", 73 | R.string.msg_mark_read_success, 74 | R.string.msg_mark_read_fail 75 | ) { 76 | companion object { 77 | fun newIntent( 78 | context: Context, 79 | notificationID: Int, 80 | email: String, 81 | backend: String, 82 | action: String, 83 | ): Intent { 84 | return Intent(context, MarkReadReceiver::class.java).let { 85 | fillIntentArgs(it, notificationID, email, backend, action) 86 | // Need to set unique action to prevent caching 87 | .setAction("MarkRead-$notificationID-${System.currentTimeMillis()}") 88 | } 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * Broadcast when the user clicks the `Trash` notification action. 95 | */ 96 | class MoveToTrashReceiver : ActionReceiver( 97 | name = "MoveToTrash", 98 | R.string.msg_trash_success, 99 | R.string.msg_trash_fail 100 | ) { 101 | companion object { 102 | fun newIntent( 103 | context: Context, 104 | notificationID: Int, 105 | email: String, 106 | backend: String, 107 | action: String 108 | ): Intent { 109 | return Intent(context, MoveToTrashReceiver::class.java).let { 110 | fillIntentArgs(it, notificationID, email, backend, action) 111 | // Need to set unique action to prevent caching 112 | .setAction("MoveToTrash-$notificationID-${System.currentTimeMillis()}") 113 | } 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Broadcast when the user clicks the `Spam` notification action. 120 | */ 121 | class MoveToSpamReceiver : ActionReceiver( 122 | "MoveToSpam", 123 | R.string.msg_spam_success, 124 | R.string.msg_spam_fail 125 | ) { 126 | companion object { 127 | fun newIntent( 128 | context: Context, 129 | notificationID: Int, 130 | email: String, 131 | backend: String, 132 | action: String 133 | ): Intent { 134 | return Intent(context, MoveToSpamReceiver::class.java).let { 135 | fillIntentArgs(it, notificationID, email, backend, action) 136 | // Need to set unique action to prevent caching 137 | .setAction("MoveToSpam-$notificationID-${System.currentTimeMillis()}") 138 | } 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * Receiver to dismiss group notifications 145 | */ 146 | class DismissGroupNotificationReceiver : BroadcastReceiver() { 147 | override fun onReceive(context: Context, intent: Intent) { 148 | val email = intent.getStringExtra(NotificationIntentEmailKey) 149 | if (email != null) { 150 | Log.i(TAG, "Dismissing Group Notification for $email") 151 | // When dismissing a group, all children are also dismissed. 152 | NOTIFICATION_STATE.dismissGroupNotification(context, email, false) 153 | } 154 | } 155 | 156 | companion object { 157 | fun newIntent( 158 | context: Context, 159 | email: String, 160 | ): Intent { 161 | return Intent(context, DismissGroupNotificationReceiver::class.java).putExtra( 162 | NotificationIntentEmailKey, 163 | email 164 | ) 165 | // Need to set unique action to prevent caching 166 | .setAction("DismissGroupNotification-${System.currentTimeMillis()}") 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * Receiver to dismiss a message notifications 173 | */ 174 | class DismissMessageNotificationReceiver : BroadcastReceiver() { 175 | override fun onReceive(context: Context, intent: Intent) { 176 | val email = intent.getStringExtra(NotificationIntentEmailKey) 177 | val backend = intent.getStringExtra(NotificationIntentBackendKey) 178 | val id = intent.getIntExtra(NotificationIDKey, 0) 179 | if (email != null && id != 0 && backend != null) { 180 | Log.i(TAG, "Dismissing Message Notification for $email id=$id") 181 | NOTIFICATION_STATE.dismissNotification(context, email, backend, id) 182 | } 183 | } 184 | 185 | companion object { 186 | fun newIntent( 187 | context: Context, 188 | email: String, 189 | backend: String, 190 | notificationID: Int, 191 | ): Intent { 192 | return Intent(context, DismissMessageNotificationReceiver::class.java).putExtra( 193 | NotificationIntentEmailKey, 194 | email 195 | ).putExtra(NotificationIDKey, notificationID) 196 | .putExtra(NotificationIntentBackendKey, backend) 197 | // Need to set unique action to prevent caching 198 | .setAction("DismissMessageNotification-$notificationID-${System.currentTimeMillis()}") 199 | } 200 | } 201 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/ActionWorker.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import android.util.Log 6 | import android.widget.Toast 7 | import androidx.work.Constraints 8 | import androidx.work.Data 9 | import androidx.work.NetworkType 10 | import androidx.work.OneTimeWorkRequest 11 | import androidx.work.WorkManager 12 | import androidx.work.Worker 13 | import androidx.work.WorkerParameters 14 | import dev.lbeernaert.youhavemail.R 15 | import dev.lbeernaert.youhavemail.Yhm 16 | 17 | private const val TAG = "ActionWorker" 18 | private const val EmailKey = "Email" 19 | private const val ActionKey = "Key" 20 | private const val ActionDescSuccessKey = "ActionDescSuccess" 21 | private const val ActionDescFailKey = "ActionDescFailure" 22 | 23 | /** 24 | * Worker which execute an action 25 | */ 26 | class ActionWorker(ctx: Context, params: WorkerParameters) : 27 | Worker(ctx, params) { 28 | 29 | override fun doWork(): Result { 30 | val email = this.inputData.getString(EmailKey) 31 | val action = this.inputData.getString(ActionKey) 32 | if (email == null || action == null) { 33 | return Result.failure() 34 | } 35 | val actionDescSuccess = 36 | this.inputData.getInt(ActionDescSuccessKey, R.string.msg_action_success) 37 | val actionDescFail = 38 | this.inputData.getInt(ActionDescFailKey, R.string.msg_action_fail) 39 | Log.i(TAG, "email=$email action=$action") 40 | val handler = Handler(applicationContext.mainLooper); 41 | return try { 42 | executeAction(applicationContext, email, action) 43 | handler.postDelayed({ 44 | Toast.makeText( 45 | applicationContext, 46 | actionDescSuccess, 47 | Toast.LENGTH_LONG 48 | ) 49 | .show() 50 | }, 1000) 51 | Result.success() 52 | } catch (e: Exception) { 53 | Log.e(TAG, "Failed to apply action: $e") 54 | handler.postDelayed({ 55 | Toast.makeText( 56 | applicationContext, 57 | actionDescFail, 58 | Toast.LENGTH_LONG 59 | ) 60 | .show() 61 | }, 1000) 62 | Result.failure() 63 | } 64 | } 65 | 66 | companion object { 67 | fun queue( 68 | context: Context, 69 | email: String, 70 | action: String, 71 | successString: Int, 72 | failureString: Int 73 | ) { 74 | val constraint = 75 | Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() 76 | val wm = WorkManager.getInstance(context) 77 | 78 | val inputData = 79 | Data.Builder().putString(EmailKey, email) 80 | .putString(ActionKey, action) 81 | .putInt(ActionDescSuccessKey, successString) 82 | .putInt(ActionDescFailKey, failureString).build() 83 | 84 | val work = OneTimeWorkRequest.Builder(ActionWorker::class.java) 85 | .setInputData(inputData).setConstraints(constraint) 86 | .build() 87 | wm.enqueue(work) 88 | } 89 | } 90 | } 91 | 92 | private fun executeAction(context: Context, email: String, action: String) { 93 | YhmInstance.get(context).yhm.applyAction(email, action) 94 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/Backends.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | const val PROTON_BACKEND_NAME = "Proton Mail" -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/LoginSequence.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import androidx.navigation.NavHostController 4 | import dev.lbeernaert.youhavemail.ProtonLoginException 5 | import dev.lbeernaert.youhavemail.ProtonLoginSequence 6 | import dev.lbeernaert.youhavemail.Proxy 7 | import dev.lbeernaert.youhavemail.Yhm 8 | import dev.lbeernaert.youhavemail.screens.Routes 9 | 10 | /** 11 | * Minor abstraction over actual login process for each backend. The backend does not need 12 | * to implement all these methods and backend specific methods can be added as needed. 13 | */ 14 | interface LoginSequence { 15 | /** 16 | * Perform login and navigate to the next screen. 17 | */ 18 | fun login(email: String, password: String) 19 | 20 | /** 21 | * Submit totp code and navigate to the next screen. 22 | */ 23 | fun totp(code: String) 24 | 25 | /** 26 | * Proton specific captcha handler. 27 | * 28 | * Submit the token and navigate to the next screen. 29 | */ 30 | fun protonCaptcha(token: String) 31 | 32 | 33 | /** 34 | * Proton specific, get captcha html. 35 | */ 36 | fun protonCaptchaHtml(): String 37 | 38 | /** 39 | * Advance to the next stage in the login sequence. 40 | */ 41 | fun next(navController: NavHostController) 42 | 43 | /** 44 | * Return the backend name for this login sequence. 45 | */ 46 | fun backendName(): String 47 | } 48 | 49 | /** 50 | * Proton Login Sequence implementation 51 | */ 52 | class ProtonLogin(state: State, proxy: Proxy?) : LoginSequence { 53 | private var mState = state 54 | private var mSequence = ProtonLoginSequence(proxy) 55 | 56 | // Email is recorded for auto captcha login 57 | private var mEmail: String = "" 58 | 59 | // Password is recorded for auto captcha login 60 | private var mPassword: String = "" 61 | private var mHumanVerificationData: String? = null 62 | private var mCaptchaHtml: String? = null 63 | 64 | override fun login(email: String, password: String) { 65 | mEmail = email 66 | mPassword = password 67 | try { 68 | mSequence.login(email, password, mHumanVerificationData) 69 | } catch (e: ProtonLoginException) { 70 | handleLoginException(e) 71 | } 72 | } 73 | 74 | override fun totp(code: String) { 75 | try { 76 | mSequence.submitTotp(code) 77 | } catch (e: ProtonLoginException) { 78 | handleLoginException(e) 79 | } 80 | } 81 | 82 | override fun protonCaptcha(token: String) { 83 | mHumanVerificationData = token 84 | mCaptchaHtml = null 85 | login(email = mEmail, password = mPassword) 86 | } 87 | 88 | override fun protonCaptchaHtml(): String { 89 | return mCaptchaHtml.orEmpty() 90 | } 91 | 92 | override fun next(navController: NavHostController) { 93 | if (mCaptchaHtml != null) { 94 | navController.navigate(Routes.ProtonCaptcha.route) 95 | } else if (mSequence.isAwaitingTotp()) { 96 | navController.navigate(Routes.TOTP.route) 97 | } else if (mSequence.isLoggedIn()) { 98 | mSequence.createAccount(mState.yhm()) 99 | navController.popBackStack(Routes.Main.route, false) 100 | } 101 | } 102 | 103 | override fun backendName(): String { 104 | return PROTON_BACKEND_NAME 105 | } 106 | 107 | private fun handleLoginException(e: ProtonLoginException) { 108 | when (e) { 109 | is ProtonLoginException.HumanVerificationRequired -> { 110 | // Avoid Loop 111 | if (mHumanVerificationData != null) { 112 | throw RuntimeException("Captcha Request Loop") 113 | } 114 | mCaptchaHtml = mSequence.captcha(e.v1.token) 115 | } 116 | 117 | else -> { 118 | throw e 119 | } 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/Logs.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import android.util.Log 6 | import android.widget.Toast 7 | import dev.lbeernaert.youhavemail.R 8 | import java.io.BufferedOutputStream 9 | import java.util.zip.ZipEntry 10 | import java.util.zip.ZipOutputStream 11 | 12 | const val logsExportLogTag = "logs-export" 13 | const val LOG_EXPORT_REQUEST = 1024 14 | 15 | fun exportLogs(context: Context, outputFile: Uri) { 16 | try { 17 | val inputDirectory = getLogPath(context) 18 | Log.d(logsExportLogTag, "Preparing log zip file: $outputFile") 19 | ZipOutputStream( 20 | BufferedOutputStream( 21 | context.contentResolver.openOutputStream(outputFile) 22 | ) 23 | ).use { zos -> 24 | inputDirectory.walkTopDown().forEach { file -> 25 | val zipFileName = 26 | file.absolutePath.removePrefix(inputDirectory.absolutePath).removePrefix("/") 27 | val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") 28 | zos.putNextEntry(entry) 29 | if (file.isFile) { 30 | Log.d(logsExportLogTag, "Adding file to zip: $zipFileName - $file") 31 | file.inputStream().use { fis -> fis.copyTo(zos) } 32 | } 33 | } 34 | } 35 | 36 | Toast.makeText( 37 | context, 38 | context.getString(R.string.export_logs_success), 39 | Toast.LENGTH_LONG 40 | ).show() 41 | } catch (e: Exception) { 42 | Log.e(logsExportLogTag, "Failed to export logs:$e") 43 | Toast.makeText( 44 | context, 45 | context.getString(R.string.export_logs_failed, e), 46 | Toast.LENGTH_LONG 47 | ).show() 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/PollWorker.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.util.Log 6 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 7 | import androidx.work.Constraints 8 | import androidx.work.Data 9 | import androidx.work.ExistingPeriodicWorkPolicy 10 | import androidx.work.ExistingWorkPolicy 11 | import androidx.work.NetworkType 12 | import androidx.work.OneTimeWorkRequest 13 | import androidx.work.PeriodicWorkRequest 14 | import androidx.work.WorkManager 15 | import androidx.work.Worker 16 | import androidx.work.WorkerParameters 17 | import androidx.work.hasKeyWithValueOfType 18 | import dev.lbeernaert.youhavemail.Event 19 | import dev.lbeernaert.youhavemail.Yhm 20 | import dev.lbeernaert.youhavemail.YhmException 21 | import dev.lbeernaert.youhavemail.initLog 22 | import dev.lbeernaert.youhavemail.yhmLogError 23 | import dev.lbeernaert.youhavemail.yhmLogInfo 24 | import java.util.concurrent.TimeUnit 25 | 26 | private const val TAG = "PollWorker" 27 | private const val TAG_ONE_SHOT = "OneShotWorker" 28 | private const val POLL_WORKER_JOB_NAME = "PollWorker" 29 | private const val ONE_SHOT_WORKER_JOB_NAME = "OneShotWorker" 30 | const val POLL_INTENT = "POLL_INTENT" 31 | const val POLL_ERROR_KEY = "POLL_ERROR" 32 | 33 | 34 | /** 35 | * Periodic Worker which polls at longer intervals. 36 | */ 37 | class PollWorker(ctx: Context, params: WorkerParameters) : 38 | Worker(ctx, params) { 39 | 40 | override fun doWork(): Result { 41 | return try { 42 | poll(applicationContext) 43 | Result.success() 44 | } catch (e: Exception) { 45 | Log.e(TAG, "Failed to send local broadcast: $e") 46 | Result.failure() 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * One time poll worker for shorter intervals. 53 | * 54 | * If no input data is specified for the interval, subsequent launches are not repeated. 55 | */ 56 | class OneTimePollWorker(ctx: Context, params: WorkerParameters) : 57 | Worker(ctx, params) { 58 | 59 | override fun doWork(): Result { 60 | try { 61 | poll(applicationContext) 62 | } catch (e: Exception) { 63 | Log.e(TAG, "Failed to poll: $e") 64 | yhmLogError("Unhandled exception: $e") 65 | return Result.failure() 66 | } 67 | 68 | try { 69 | if (inputData.hasKeyWithValueOfType("INTERVAL")) { 70 | val interval = inputData.getLong("INTERVAL", 15) 71 | registerWorker(applicationContext, interval, false) 72 | } 73 | } catch (e: Exception) { 74 | yhmLogError("Failed to re-register one time poll worker: $e") 75 | createServiceErrorNotification(applicationContext, "Failed to create worker: $e") 76 | } 77 | 78 | return Result.success() 79 | } 80 | } 81 | 82 | /** 83 | * Poll all accounts. 84 | */ 85 | private fun poll(context: Context) { 86 | val yhm: Yhm 87 | try { 88 | yhm = YhmInstance.get(context).yhm 89 | } catch (e: YhmException) { 90 | createServiceErrorNotification(context, "Failed to Create Yhm", e) 91 | return 92 | } 93 | 94 | var error: String? = null 95 | try { 96 | yhm.poll() 97 | try { 98 | val events = yhm.lastEvents() 99 | for (event in events) { 100 | when (event) { 101 | is Event.Email -> { 102 | for (email in event.emails) { 103 | NOTIFICATION_STATE.onNewEmail( 104 | context, 105 | event.email, 106 | event.backend, 107 | email, 108 | ) 109 | } 110 | } 111 | 112 | is Event.Error -> { 113 | NOTIFICATION_STATE.onError(context, event.v1, event.v2) 114 | } 115 | 116 | is Event.LoggedOut -> { 117 | NOTIFICATION_STATE.onLoggedOut( 118 | context, 119 | event.v1, 120 | ) 121 | } 122 | 123 | is Event.Offline -> { 124 | // Do nothing. 125 | } 126 | } 127 | } 128 | } catch (e: Exception) { 129 | createServiceErrorNotification(context, "Failed to retrieve events: $e") 130 | } 131 | } catch (e: YhmException) { 132 | error = e.toString() 133 | createServiceErrorNotification(context, error) 134 | } 135 | 136 | 137 | // While it would be preferable to have a watcher into the database detect, these changes and 138 | // since currently there exists no such thing for rust, we simply broad cast the success of 139 | // this work and let the main activity handle the notification state. 140 | 141 | try { 142 | val localIntent = Intent(POLL_INTENT) 143 | localIntent.putExtra(POLL_ERROR_KEY, error) 144 | LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent) 145 | } catch (e: Exception) { 146 | yhmLogError("Failed to publish broadcast: $e") 147 | createServiceErrorNotification(context, "Failed to publish broadcast: $e") 148 | } 149 | } 150 | 151 | /** 152 | * Get worker constraints 153 | */ 154 | private fun constraints(): Constraints { 155 | return Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() 156 | } 157 | 158 | fun registerWorker(ctx: Context, minutes: Long, cancel: Boolean) { 159 | initLog(getLogPath(ctx).path) 160 | val inputData = Data.Builder().putLong("INTERVAL", minutes).build() 161 | val constraints = constraints() 162 | val wm = WorkManager.getInstance(ctx) 163 | 164 | if (cancel) { 165 | wm.cancelAllWorkByTag(TAG) 166 | } 167 | 168 | if (minutes >= 15) { 169 | Log.d(TAG, "Registering Periodic work with $minutes minutes interval") 170 | 171 | val work = 172 | PeriodicWorkRequest.Builder(PollWorker::class.java, minutes, TimeUnit.MINUTES) 173 | .addTag(TAG) 174 | .setInputData(inputData) 175 | .setConstraints(constraints) 176 | .build() 177 | wm.enqueueUniquePeriodicWork( 178 | POLL_WORKER_JOB_NAME, 179 | ExistingPeriodicWorkPolicy.KEEP, 180 | work 181 | ) 182 | } else { 183 | Log.d(TAG, "Registering One Time work with $minutes minutes interval") 184 | val work = OneTimeWorkRequest.Builder(OneTimePollWorker::class.java).addTag(TAG) 185 | .setInputData(inputData).setConstraints(constraints) 186 | .setInitialDelay(minutes, TimeUnit.MINUTES) 187 | .build() 188 | 189 | wm.enqueueUniqueWork( 190 | POLL_WORKER_JOB_NAME, 191 | ExistingWorkPolicy.REPLACE, 192 | work 193 | ) 194 | } 195 | } 196 | 197 | /** 198 | * Register a poll job that only runs once. 199 | */ 200 | fun oneshotWorker(ctx: Context) { 201 | Log.d(TAG, "Registering one time shot worker") 202 | val work = OneTimeWorkRequest.Builder(OneTimePollWorker::class.java).addTag(TAG_ONE_SHOT) 203 | .setConstraints(constraints()) 204 | .build() 205 | 206 | val wm = WorkManager.getInstance(ctx) 207 | wm.enqueueUniqueWork(ONE_SHOT_WORKER_JOB_NAME, ExistingWorkPolicy.KEEP, work) 208 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/StartReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import dev.lbeernaert.youhavemail.Yhm 8 | import dev.lbeernaert.youhavemail.YhmException 9 | 10 | class StartReceiver : BroadcastReceiver() { 11 | override fun onReceive(context: Context, intent: Intent) { 12 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) { 13 | Log.i("BOOT", "Received boot notification") 14 | 15 | try { 16 | registerWorker( 17 | context, 18 | YhmInstance.get(context).yhm.pollInterval().toLong() / 60, 19 | false 20 | ) 21 | } catch (e: YhmException) { 22 | createServiceErrorNotification( 23 | context, 24 | "Failed to Create Yhm on boot and register work", 25 | e 26 | ) 27 | return 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/State.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.util.Log 6 | import androidx.security.crypto.EncryptedSharedPreferences 7 | import androidx.security.crypto.MasterKey 8 | import dev.lbeernaert.youhavemail.Account 9 | import dev.lbeernaert.youhavemail.AccountWatcher 10 | import dev.lbeernaert.youhavemail.Backend 11 | import dev.lbeernaert.youhavemail.Event 12 | import dev.lbeernaert.youhavemail.Proxy 13 | import dev.lbeernaert.youhavemail.WatchHandle 14 | import dev.lbeernaert.youhavemail.Yhm 15 | import dev.lbeernaert.youhavemail.activityLogTag 16 | import dev.lbeernaert.youhavemail.newEncryptionKey 17 | import dev.lbeernaert.youhavemail.yhmLogError 18 | import dev.lbeernaert.youhavemail.yhmLogInfo 19 | import kotlinx.coroutines.flow.MutableStateFlow 20 | import kotlinx.coroutines.flow.StateFlow 21 | import java.io.File 22 | 23 | /* 24 | data class ServiceAccount( 25 | val email: String, 26 | val backend: String, 27 | val state: MutableStateFlow, 28 | val proxy: MutableStateFlow 29 | ) 30 | 31 | class ObserverServiceState { 32 | private var mAccounts: ArrayList = ArrayList() 33 | private var mAccountsFlow: MutableStateFlow> = 34 | MutableStateFlow(ArrayList()) 35 | */ 36 | 37 | const val STATE_LOG_TAG = "state" 38 | 39 | // Has to be global singleton for now so that the ids are accessible for the worker 40 | // and the system. Could be moved to a shared preferences setup for persistent 41 | // changes. 42 | var NOTIFICATION_STATE = NotificationState() 43 | 44 | class State(context: Context) : AccountWatcher { 45 | private var mPollInterval = MutableStateFlow(15UL) 46 | private var mYhm: Yhm = YhmInstance.get(context).yhm 47 | private var mAccounts: MutableStateFlow> 48 | private var mOpenAccount: MutableStateFlow = MutableStateFlow(null) 49 | var mLoginSequence: LoginSequence? = null 50 | private var mWatchHandle: WatchHandle? = null 51 | 52 | init { 53 | mWatchHandle = mYhm.watchAccounts(this) 54 | mAccounts = MutableStateFlow(mYhm.accounts()) 55 | val pollInterval = mYhm.pollInterval() 56 | mPollInterval.value = pollInterval 57 | registerWorker(context, pollInterval.toLong() / 60, false) 58 | } 59 | 60 | 61 | fun migrateAccounts(context: Context) { 62 | val file = getConfigFilePathV1(context) 63 | if (!file.exists()) { 64 | return 65 | } 66 | try { 67 | yhmLogInfo("Found v1 config, importing...") 68 | mYhm.importV1(file.path) 69 | yhmLogInfo("Found v1 config, importing...Done") 70 | } catch (e: Exception) { 71 | yhmLogError("Failed to migrate: $e") 72 | try { 73 | createAndDisplayServiceErrorNotification(context, "Failed to migrate accounts: $e") 74 | } catch (e: Exception) { 75 | Log.e(activityLogTag, "Failed to create exception") 76 | } 77 | } finally { 78 | try { 79 | file.delete() 80 | } catch (e: Exception) { 81 | yhmLogError("Failed to delete old config file: $e") 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Update poll interval. 88 | * 89 | */ 90 | fun setPollInterval(context: Context, intervalSeconds: ULong) { 91 | Log.i(STATE_LOG_TAG, "Interval $intervalSeconds Seconds") 92 | mYhm.setPollInterval(intervalSeconds) 93 | mPollInterval.value = intervalSeconds 94 | registerWorker(context, (intervalSeconds.toLong() / 60), true) 95 | } 96 | 97 | /** 98 | * Get the current poll interval 99 | */ 100 | fun getPollInterval(): StateFlow { 101 | return mPollInterval 102 | } 103 | 104 | /** 105 | * Get list of known accounts 106 | */ 107 | fun accounts(): StateFlow> { 108 | return mAccounts 109 | } 110 | 111 | /** 112 | * Get list of backends. 113 | */ 114 | fun backends(): List { 115 | return mYhm.backends() 116 | } 117 | 118 | /** 119 | * Open an account fo detailed inspection 120 | */ 121 | fun account(email: String): Account? { 122 | return mYhm.account(email) 123 | } 124 | 125 | /** 126 | * Logout account by email. 127 | */ 128 | fun logout(email: String) { 129 | mYhm.logout(email) 130 | } 131 | 132 | /** 133 | * Delete an account by email. 134 | */ 135 | fun delete(email: String) { 136 | mYhm.delete(email) 137 | } 138 | 139 | fun yhm(): Yhm { 140 | return mYhm 141 | } 142 | 143 | /** 144 | * Create a new login sequence for a given backend. 145 | */ 146 | fun newLoginSequence(backendName: String, proxy: Proxy?): LoginSequence { 147 | if (backendName == PROTON_BACKEND_NAME) { 148 | return ProtonLogin(this, proxy) 149 | } 150 | 151 | throw RuntimeException("Unknown backend") 152 | } 153 | 154 | /** 155 | * Unregister receiver and release resource. 156 | */ 157 | fun close() { 158 | Log.i(STATE_LOG_TAG, "Closing") 159 | mAccounts.value = ArrayList() 160 | mLoginSequence = null 161 | mWatchHandle = null 162 | } 163 | 164 | override fun onAccountsUpdated(accounts: List) { 165 | if (mOpenAccount.value != null) { 166 | val accountEmail = mOpenAccount.value!!.email() 167 | mOpenAccount.value = accounts.find { 168 | it.email() == accountEmail 169 | } 170 | } 171 | mAccounts.value = accounts 172 | } 173 | } 174 | 175 | /** 176 | * Get configuration file path. 177 | */ 178 | private fun getConfigFilePathV1(context: Context): File { 179 | return File(context.filesDir.canonicalPath, "config_v2") 180 | } 181 | 182 | /** 183 | * Get database path. 184 | */ 185 | fun getDatabasePath(context: Context): String { 186 | return context.filesDir.canonicalPath + "/yhm.db" 187 | } 188 | 189 | /** 190 | * Get log path. 191 | */ 192 | fun getLogPath(context: Context): File { 193 | return File(context.filesDir.canonicalPath, "logs") 194 | } 195 | 196 | /** 197 | * Load encryption key for the application. 198 | */ 199 | fun getOrCreateEncryptionKey(context: Context): String { 200 | Log.d(STATE_LOG_TAG, "Loading Encryption key") 201 | val preferences = getSharedPreferences(context) 202 | val key = preferences.getString("KEY", null) 203 | return if (key == null) { 204 | Log.d(STATE_LOG_TAG, "No key exists, creating new key") 205 | val newKey = newEncryptionKey() 206 | preferences.edit().putString("KEY", newKey).apply() 207 | newKey 208 | } else { 209 | key 210 | } 211 | } 212 | 213 | /** 214 | * Get the encrypted shared preferences for this application. 215 | */ 216 | private fun getSharedPreferences(context: Context): SharedPreferences { 217 | val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) 218 | .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) 219 | .build() 220 | 221 | return EncryptedSharedPreferences.create( 222 | context, 223 | // passing a file name to share a preferences 224 | "preferences", 225 | masterKey, 226 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 227 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 228 | ) 229 | } 230 | 231 | /** 232 | * Get the application we should open based on the backend name. 233 | */ 234 | fun getAppNameForBackend(backend: String): String? { 235 | return when (backend) { 236 | "Proton Mail" -> "ch.protonmail.android" 237 | "Proton Mail V-Other" -> "ch.protonmail.android" 238 | "Null Backend" -> "ch.protonmail.android" 239 | 240 | else -> null 241 | } 242 | } 243 | 244 | fun accountStatusString(account: Account): String { 245 | var statusString = "" 246 | if (account.isLoggedOut()) { 247 | statusString = "Logged Out" 248 | } else { 249 | val last_poll = account.lastPoll() 250 | if (last_poll == null) { 251 | statusString = "Not Polled" 252 | } else { 253 | val last_event = account.lastEvent() 254 | if (last_event != null) { 255 | var result = "Polled" 256 | when (last_event) { 257 | is Event.Error -> { 258 | result = "Error" 259 | } 260 | 261 | is Event.Offline -> { 262 | result = "Offline" 263 | } 264 | 265 | else -> { 266 | 267 | } 268 | } 269 | statusString = "$result (${last_poll})" 270 | } 271 | } 272 | } 273 | 274 | return statusString 275 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/app/YhmInstance.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.app 2 | 3 | import android.content.Context 4 | import dev.lbeernaert.youhavemail.Yhm 5 | 6 | /** 7 | * Yhm Singleton. 8 | */ 9 | class YhmInstance private constructor(var yhm: Yhm) { 10 | companion object { 11 | @Volatile 12 | private var instance: YhmInstance? = null 13 | 14 | /** 15 | * Get or create a Yhm instance. 16 | * 17 | * Throws exception on failure. 18 | */ 19 | fun get(context: Context): YhmInstance { 20 | if (instance != null) { 21 | return instance!! 22 | } 23 | synchronized(this) { 24 | if (instance == null) { 25 | val key = getOrCreateEncryptionKey(context) 26 | val dbPath = getDatabasePath(context) 27 | instance = YhmInstance(Yhm(dbPath, encryptionKey = key)) 28 | } 29 | 30 | return instance!! 31 | } 32 | } 33 | } 34 | 35 | 36 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/components/ActionButton.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.material.Button 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | 11 | @Composable 12 | fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean = true) { 13 | Button( 14 | onClick = onClick, 15 | modifier = Modifier 16 | .fillMaxWidth() 17 | .height(50.dp), 18 | enabled = enabled, 19 | ) { 20 | Text(text = text) 21 | } 22 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/components/AsyncScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.components 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.material.Icon 6 | import androidx.compose.material.IconButton 7 | import androidx.compose.material.Scaffold 8 | import androidx.compose.material.SnackbarDuration 9 | import androidx.compose.material.Text 10 | import androidx.compose.material.TopAppBar 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 13 | import androidx.compose.material.rememberScaffoldState 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.rememberCoroutineScope 18 | import kotlinx.coroutines.launch 19 | 20 | const val asyncScreenLogTag = "async" 21 | 22 | @Composable 23 | fun AsyncScreen( 24 | title: String, 25 | onBackClicked: () -> Unit, 26 | content: @Composable (padding: PaddingValues, run: (String, suspend () -> Unit) -> Unit) -> Unit 27 | ) { 28 | val scaffoldState = rememberScaffoldState() 29 | val openDialog = remember { mutableStateOf(false) } 30 | val coroutineScope = rememberCoroutineScope() 31 | val backgroundText = remember { 32 | mutableStateOf("") 33 | } 34 | 35 | val setAsyncTaskLabel: (String) -> Unit = { 36 | backgroundText.value = it 37 | } 38 | 39 | if (openDialog.value) { 40 | BackgroundTask( 41 | text = backgroundText.value 42 | ) 43 | } else { 44 | Scaffold( 45 | scaffoldState = scaffoldState, 46 | topBar = { 47 | TopAppBar(title = { 48 | Text(text = title) 49 | }, 50 | navigationIcon = { 51 | IconButton(onClick = onBackClicked) { 52 | Icon( 53 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 54 | contentDescription = "Back" 55 | ) 56 | } 57 | }) 58 | } 59 | ) { padding -> 60 | content(padding) { label, run -> 61 | coroutineScope.launch { 62 | setAsyncTaskLabel(label) 63 | openDialog.value = true 64 | try { 65 | run() 66 | } catch (e: Exception) { 67 | openDialog.value = false 68 | Log.e(asyncScreenLogTag, e.toString()) 69 | coroutineScope.launch { 70 | scaffoldState.snackbarHostState.showSnackbar( 71 | message = e.message.orEmpty(), 72 | duration = SnackbarDuration.Short, 73 | ) 74 | } 75 | } catch (err: Exception) { 76 | openDialog.value = false 77 | Log.e(asyncScreenLogTag, err.toString()) 78 | coroutineScope.launch { 79 | scaffoldState.snackbarHostState.showSnackbar( 80 | message = "Unknown Error", 81 | duration = SnackbarDuration.Short, 82 | ) 83 | } 84 | } finally { 85 | openDialog.value = false 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/components/BackgroundTask.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.CircularProgressIndicator 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.unit.dp 11 | import dev.lbeernaert.youhavemail.R 12 | 13 | @Composable 14 | fun BackgroundTask(text: String) { 15 | Column( 16 | modifier = Modifier 17 | .padding(10.dp) 18 | .fillMaxSize(), 19 | verticalArrangement = Arrangement.Center, 20 | horizontalAlignment = Alignment.CenterHorizontally, 21 | 22 | ) { 23 | CircularProgressIndicator() 24 | Spacer(modifier = Modifier.padding(10.dp)) 25 | Text(text = text) 26 | } 27 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/components/PasswordField.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.text.KeyboardActions 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.IconButton 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.TextField 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Visibility 12 | import androidx.compose.material.icons.filled.VisibilityOff 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.MutableState 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.ui.ExperimentalComposeUiApi 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.autofill.AutofillType 20 | import androidx.compose.ui.text.input.ImeAction 21 | import androidx.compose.ui.text.input.KeyboardType 22 | import androidx.compose.ui.text.input.PasswordVisualTransformation 23 | import androidx.compose.ui.text.input.TextFieldValue 24 | import androidx.compose.ui.text.input.VisualTransformation 25 | import dev.lbeernaert.youhavemail.ui.AutoFillRequestHandler 26 | import dev.lbeernaert.youhavemail.ui.autofill 27 | 28 | 29 | @OptIn(ExperimentalComposeUiApi::class) 30 | @Composable 31 | fun PasswordField( 32 | placeHolder: String, 33 | state: MutableState, 34 | onClick: () -> Unit 35 | ) { 36 | val showPassword = remember { 37 | mutableStateOf(false) 38 | } 39 | val autoFillHandler = AutoFillRequestHandler( 40 | autofillTypes = listOf(AutofillType.Password), 41 | onFill = { state.value = TextFieldValue(it) } 42 | ) 43 | 44 | TextField( 45 | modifier = Modifier 46 | .fillMaxWidth() 47 | .autofill(handler = autoFillHandler), 48 | label = { Text(text = placeHolder) }, 49 | value = state.value, 50 | singleLine = true, 51 | visualTransformation = if (!showPassword.value) { 52 | PasswordVisualTransformation() 53 | } else { 54 | VisualTransformation.None 55 | }, 56 | keyboardOptions = KeyboardOptions( 57 | keyboardType = KeyboardType.Password, 58 | imeAction = ImeAction.Done 59 | ), 60 | onValueChange = { state.value = it }, 61 | keyboardActions = KeyboardActions(onDone = { 62 | onClick() 63 | }), 64 | trailingIcon = { 65 | val icon = if (showPassword.value) { 66 | Icons.Filled.Visibility 67 | } else { 68 | Icons.Filled.VisibilityOff 69 | } 70 | 71 | IconButton(onClick = { showPassword.value = !showPassword.value }) { 72 | Icon( 73 | icon, 74 | contentDescription = "Visibility", 75 | ) 76 | } 77 | } 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/AccountInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.Divider 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.unit.dp 21 | import dev.lbeernaert.youhavemail.Account 22 | import dev.lbeernaert.youhavemail.R 23 | import dev.lbeernaert.youhavemail.app.accountStatusString 24 | import dev.lbeernaert.youhavemail.components.ActionButton 25 | import dev.lbeernaert.youhavemail.components.AsyncScreen 26 | 27 | 28 | @Composable 29 | fun AccountInfo( 30 | account: Account, 31 | onBackClicked: () -> Unit, 32 | onLogout: suspend () -> Unit, 33 | onLogin: () -> Unit, 34 | onDelete: suspend () -> Unit, 35 | onProxyClicked: () -> Unit, 36 | ) { 37 | 38 | AsyncScreen( 39 | title = stringResource(id = R.string.account_title), 40 | onBackClicked = onBackClicked 41 | ) { padding, runTask -> 42 | 43 | val logOutBackgroundLabel = stringResource(id = R.string.logging_out) 44 | val onLogoutImpl: () -> Unit = { 45 | runTask(logOutBackgroundLabel) { 46 | onLogout() 47 | } 48 | } 49 | 50 | val onDeleteImpl: () -> Unit = { 51 | runTask(logOutBackgroundLabel) { 52 | onDelete() 53 | } 54 | } 55 | 56 | Column( 57 | modifier = Modifier 58 | .padding(padding) 59 | .padding(20.dp) 60 | .fillMaxSize() 61 | .verticalScroll(rememberScrollState(), true), 62 | verticalArrangement = Arrangement.Top, 63 | horizontalAlignment = Alignment.CenterHorizontally, 64 | ) { 65 | Text( 66 | text = "Email", 67 | modifier = Modifier.fillMaxWidth(), 68 | fontWeight = FontWeight.Bold, 69 | style = MaterialTheme.typography.h5, 70 | ) 71 | 72 | Text( 73 | text = account.email(), 74 | modifier = Modifier.fillMaxWidth(), 75 | ) 76 | 77 | Spacer(modifier = Modifier.height(20.dp)) 78 | 79 | Text( 80 | text = "Backend", 81 | modifier = Modifier.fillMaxWidth(), 82 | fontWeight = FontWeight.Bold, 83 | style = MaterialTheme.typography.h5, 84 | ) 85 | 86 | Text( 87 | text = account.backend(), 88 | modifier = Modifier.fillMaxWidth() 89 | ) 90 | 91 | Spacer(modifier = Modifier.height(20.dp)) 92 | 93 | Text( 94 | text = stringResource(id = R.string.status_no_colon), 95 | fontWeight = FontWeight.Bold, 96 | modifier = Modifier.fillMaxWidth(), 97 | style = MaterialTheme.typography.h5, 98 | ) 99 | 100 | Text( 101 | text = accountStatusString(account), 102 | modifier = Modifier.fillMaxWidth() 103 | ) 104 | 105 | Spacer(modifier = Modifier.height(20.dp)) 106 | 107 | Divider() 108 | 109 | Spacer(modifier = Modifier.height(20.dp)) 110 | 111 | ActionButton(text = stringResource(id = R.string.proxy_settings), onProxyClicked) 112 | 113 | Spacer(modifier = Modifier.height(40.dp)) 114 | 115 | if (account.isLoggedOut()) { 116 | ActionButton( 117 | text = stringResource(id = R.string.login), 118 | onClick = onLogin 119 | ) 120 | } else { 121 | ActionButton( 122 | text = stringResource(id = R.string.logout), 123 | onClick = onLogoutImpl, 124 | ) 125 | } 126 | 127 | Spacer(modifier = Modifier.height(20.dp)) 128 | 129 | ActionButton(text = stringResource(id = R.string.delete_account), onDeleteImpl) 130 | 131 | Spacer(modifier = Modifier.height(40.dp)) 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/BackendSelection.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.itemsIndexed 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 11 | import androidx.compose.material.icons.filled.ArrowBack 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import androidx.navigation.NavController 20 | import dev.lbeernaert.youhavemail.Backend 21 | import dev.lbeernaert.youhavemail.R 22 | import dev.lbeernaert.youhavemail.app.State 23 | 24 | @Composable 25 | fun BackendSelection(state: State, navController: NavController) { 26 | Scaffold(topBar = { 27 | TopAppBar(title = { 28 | Text(text = stringResource(id = R.string.backend_title)) 29 | }, 30 | navigationIcon = 31 | { 32 | IconButton(onClick = { 33 | navController.popBackStack() 34 | }) { 35 | Icon( 36 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 37 | contentDescription = "Back" 38 | ) 39 | } 40 | } 41 | ) 42 | }, 43 | content = { _ -> 44 | BackendList(backends = state.backends(), onClicked = { 45 | navController.navigate(Routes.newProxyLoginRoute(it)) 46 | }) 47 | } 48 | ) 49 | } 50 | 51 | @Composable 52 | fun BackendListItem(backend: Backend, onClicked: (String) -> Unit) { 53 | Row( 54 | modifier = Modifier 55 | .padding(10.dp) 56 | .fillMaxWidth() 57 | .clickable { onClicked(backend.name()) }, 58 | ) { 59 | val name = backend.name() 60 | Column( 61 | verticalArrangement = Arrangement.Center, 62 | modifier = Modifier 63 | .size(60.dp) 64 | .background(MaterialTheme.colors.primary, MaterialTheme.shapes.large), 65 | ) { 66 | Text( 67 | modifier = Modifier.fillMaxWidth(), 68 | text = name.first().toString(), 69 | textAlign = TextAlign.Center, 70 | style = MaterialTheme.typography.button, 71 | fontWeight = FontWeight.Bold, 72 | fontSize = 30.sp 73 | ) 74 | } 75 | Spacer(modifier = Modifier.width(10.dp)) 76 | Column(modifier = Modifier.fillMaxWidth()) { 77 | Text( 78 | text = name, 79 | style = MaterialTheme.typography.subtitle1, 80 | fontWeight = FontWeight.Bold 81 | ) 82 | Text(text = backend.description(), style = MaterialTheme.typography.body2) 83 | } 84 | } 85 | } 86 | 87 | @Composable 88 | fun BackendList(backends: List, onClicked: (index: String) -> Unit) { 89 | LazyColumn(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 10.dp)) { 90 | itemsIndexed(backends) { _, backend -> 91 | BackendListItem(backend = backend, onClicked = onClicked) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Login.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.text.KeyboardOptions 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.TextField 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.saveable.rememberSaveable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.ExperimentalComposeUiApi 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.autofill.AutofillType 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.text.input.ImeAction 23 | import androidx.compose.ui.text.input.KeyboardType 24 | import androidx.compose.ui.text.input.TextFieldValue 25 | import androidx.compose.ui.unit.dp 26 | import dev.lbeernaert.youhavemail.R 27 | import dev.lbeernaert.youhavemail.components.ActionButton 28 | import dev.lbeernaert.youhavemail.components.AsyncScreen 29 | import dev.lbeernaert.youhavemail.components.PasswordField 30 | import dev.lbeernaert.youhavemail.ui.AutoFillRequestHandler 31 | import dev.lbeernaert.youhavemail.ui.autofill 32 | 33 | 34 | @OptIn(ExperimentalComposeUiApi::class) 35 | @Composable 36 | fun Login( 37 | backendName: String, 38 | accountEmail: String, 39 | onBackClicked: () -> Unit, 40 | onLoginClicked: suspend (email: String, password: String) -> Unit 41 | ) { 42 | val email = rememberSaveable(stateSaver = TextFieldValue.Saver) { 43 | mutableStateOf( 44 | TextFieldValue(accountEmail) 45 | ) 46 | } 47 | val password = 48 | remember { mutableStateOf(TextFieldValue()) } 49 | val loginBackgroundLabel = stringResource(id = R.string.login_to_account, email.value.text) 50 | 51 | AsyncScreen( 52 | title = stringResource(id = R.string.login), 53 | onBackClicked = onBackClicked 54 | ) { padding, runTask -> 55 | 56 | val onClick: () -> Unit = { 57 | runTask(loginBackgroundLabel) { 58 | onLoginClicked(email.value.text, password.value.text) 59 | } 60 | } 61 | val autoFillHandler = AutoFillRequestHandler( 62 | autofillTypes = listOf(AutofillType.EmailAddress), 63 | onFill = { email.value = TextFieldValue(it) } 64 | ) 65 | 66 | Column( 67 | modifier = Modifier 68 | .padding(padding) 69 | .padding(20.dp) 70 | .fillMaxSize(), 71 | verticalArrangement = Arrangement.Center, 72 | horizontalAlignment = Alignment.CenterHorizontally, 73 | 74 | ) { 75 | Text(text = stringResource(R.string.login_to_account, backendName)) 76 | 77 | Spacer(modifier = Modifier.height(20.dp)) 78 | 79 | TextField( 80 | modifier = Modifier.fillMaxWidth() 81 | .autofill(handler = autoFillHandler), 82 | label = { Text(text = "Email") }, 83 | singleLine = true, 84 | value = email.value, 85 | onValueChange = { email.value = it }, 86 | keyboardOptions = KeyboardOptions( 87 | keyboardType = KeyboardType.Email, 88 | imeAction = ImeAction.Next 89 | ), 90 | ) 91 | 92 | Spacer(modifier = Modifier.height(20.dp)) 93 | 94 | PasswordField(placeHolder = "Password", state = password, onClick = onClick) 95 | 96 | Spacer(modifier = Modifier.height(20.dp)) 97 | 98 | ActionButton(text = stringResource(id = R.string.login), onClick) 99 | } 100 | 101 | } 102 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Main.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.PaddingValues 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.foundation.lazy.LazyColumn 16 | import androidx.compose.foundation.lazy.itemsIndexed 17 | import androidx.compose.material.Button 18 | import androidx.compose.material.ButtonDefaults 19 | import androidx.compose.material.FabPosition 20 | import androidx.compose.material.FloatingActionButton 21 | import androidx.compose.material.Icon 22 | import androidx.compose.material.MaterialTheme 23 | import androidx.compose.material.Scaffold 24 | import androidx.compose.material.Text 25 | import androidx.compose.material.TopAppBar 26 | import androidx.compose.material.icons.Icons 27 | import androidx.compose.material.icons.filled.Add 28 | import androidx.compose.material.icons.filled.Refresh 29 | import androidx.compose.material.icons.filled.Settings 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.collectAsState 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.runtime.mutableStateOf 34 | import androidx.compose.runtime.remember 35 | import androidx.compose.runtime.setValue 36 | import androidx.compose.ui.Alignment 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.res.stringResource 39 | import androidx.compose.ui.text.font.FontWeight 40 | import androidx.compose.ui.text.style.TextAlign 41 | import androidx.compose.ui.unit.dp 42 | import androidx.compose.ui.unit.sp 43 | import androidx.navigation.NavController 44 | import dev.lbeernaert.youhavemail.Account 45 | import dev.lbeernaert.youhavemail.R 46 | import dev.lbeernaert.youhavemail.app.State 47 | import dev.lbeernaert.youhavemail.app.accountStatusString 48 | import java.util.Timer 49 | import java.util.TimerTask 50 | 51 | @Composable 52 | fun Main( 53 | state: State, 54 | navController: NavController, 55 | requestPermissions: () -> Unit, 56 | onSettingsClicked: () -> Unit, 57 | onPollClicked: () -> Unit, 58 | ) { 59 | AccountList( 60 | state = state, 61 | navController = navController, 62 | requestPermissions = requestPermissions, 63 | onSettingsClicked = onSettingsClicked, 64 | onPollClicked = onPollClicked, 65 | ) 66 | } 67 | 68 | 69 | @Composable 70 | fun AccountList( 71 | state: State, 72 | navController: NavController, 73 | requestPermissions: () -> Unit, 74 | onSettingsClicked: () -> Unit, 75 | onPollClicked: () -> Unit, 76 | ) { 77 | val accounts by state.accounts().collectAsState() 78 | var pollActive by remember { mutableStateOf(true) } 79 | 80 | Scaffold(topBar = { 81 | TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) }, actions = { 82 | Button( 83 | onClick = { 84 | onPollClicked() 85 | pollActive = false 86 | Timer().schedule( 87 | object : TimerTask() { 88 | override fun run() { 89 | pollActive = true 90 | } 91 | }, 15000 92 | ) 93 | }, 94 | colors = ButtonDefaults.outlinedButtonColors(), 95 | enabled = pollActive, 96 | ) { 97 | Icon( 98 | Icons.Filled.Refresh, 99 | contentDescription = "Poll accounts", 100 | modifier = Modifier.size(30.dp) 101 | ) 102 | } 103 | Spacer(modifier = Modifier.size(10.dp)) 104 | Button( 105 | onClick = onSettingsClicked, 106 | colors = ButtonDefaults.outlinedButtonColors() 107 | ) { 108 | Icon( 109 | Icons.Filled.Settings, 110 | contentDescription = "Settings", 111 | modifier = Modifier.size(30.dp) 112 | ) 113 | } 114 | }) 115 | }, 116 | floatingActionButtonPosition = FabPosition.End, 117 | floatingActionButton = { 118 | FloatingActionButton(onClick = { 119 | requestPermissions() 120 | navController.navigate(Routes.Backend.route) 121 | }) { 122 | Icon(Icons.Filled.Add, "") 123 | } 124 | }, 125 | content = { padding -> 126 | val isEmpty = accounts.isEmpty() 127 | Column( 128 | modifier = Modifier 129 | .padding(padding) 130 | .fillMaxSize(), 131 | verticalArrangement = if (isEmpty) { 132 | Arrangement.Center 133 | } else { 134 | Arrangement.Top 135 | }, 136 | horizontalAlignment = if (isEmpty) { 137 | Alignment.CenterHorizontally 138 | } else { 139 | Alignment.Start 140 | } 141 | 142 | ) { 143 | if (accounts.isEmpty()) { 144 | Text(text = stringResource(id = R.string.no_accounts)) 145 | } else { 146 | LazyColumn( 147 | contentPadding = PaddingValues( 148 | horizontal = 10.dp, 149 | vertical = 10.dp 150 | ) 151 | ) { 152 | itemsIndexed(accounts) { _, account -> 153 | ActiveAccount(account = account, onClicked = { 154 | navController.navigate(Routes.newAccountRoute(it)) 155 | }) 156 | } 157 | } 158 | } 159 | } 160 | }) 161 | } 162 | 163 | 164 | @Composable 165 | fun ActiveAccount(account: Account, onClicked: (String) -> Unit) { 166 | Row( 167 | modifier = Modifier 168 | .padding(10.dp) 169 | .fillMaxWidth() 170 | .clickable { onClicked(account.email()) }, 171 | ) { 172 | val email = account.email() 173 | 174 | Column( 175 | verticalArrangement = Arrangement.Center, 176 | modifier = Modifier 177 | .size(60.dp) 178 | .background(MaterialTheme.colors.primary, MaterialTheme.shapes.large), 179 | ) { 180 | Text( 181 | modifier = Modifier.fillMaxWidth(), 182 | text = email.first().toString().uppercase(), 183 | textAlign = TextAlign.Center, 184 | style = MaterialTheme.typography.button, 185 | fontWeight = FontWeight.Bold, 186 | fontSize = 30.sp 187 | ) 188 | } 189 | Spacer(modifier = Modifier.width(10.dp)) 190 | Column(modifier = Modifier.fillMaxWidth()) { 191 | Text( 192 | text = email, 193 | style = MaterialTheme.typography.subtitle1, 194 | fontWeight = FontWeight.Bold 195 | ) 196 | 197 | Text( 198 | text = stringResource(id = R.string.status, accountStatusString(account)), 199 | style = MaterialTheme.typography.body2 200 | ) 201 | } 202 | } 203 | } 204 | 205 | 206 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Nav.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import android.app.Activity 4 | import android.util.Log 5 | import android.widget.Toast 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import androidx.navigation.NavType 9 | import androidx.navigation.compose.NavHost 10 | import androidx.navigation.compose.composable 11 | import androidx.navigation.compose.rememberNavController 12 | import androidx.navigation.navArgument 13 | import dev.lbeernaert.youhavemail.R 14 | import dev.lbeernaert.youhavemail.app.State 15 | import dev.lbeernaert.youhavemail.testProxy 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.withContext 18 | 19 | const val navLogTag = "nav" 20 | 21 | @Composable 22 | fun MainNavController( 23 | context: Activity, 24 | state: State, 25 | requestPermissions: () -> Unit, 26 | onPollClicked: () -> Unit, 27 | onExportLogsClicked: () -> Unit 28 | ) { 29 | val navController = rememberNavController() 30 | 31 | NavHost(navController = navController, startDestination = Routes.Main.route) { 32 | // ------------------ LOGIN -------------------------------------------------------------- 33 | composable( 34 | Routes.Login.route, 35 | arguments = listOf( 36 | navArgument("email") { 37 | type = NavType.StringType 38 | defaultValue = "" 39 | }) 40 | ) { 41 | val args = it.arguments 42 | val accountEmail = args?.getString("email").orEmpty() 43 | if (state.mLoginSequence == null) { 44 | Log.e(navLogTag, "No backend index selected, returning to main screen") 45 | navController.popBackStack(Routes.Main.route, false) 46 | } else { 47 | Login( 48 | accountEmail = accountEmail, 49 | backendName = state.mLoginSequence!!.backendName(), 50 | onBackClicked = { 51 | navController.popBackStack() 52 | }, 53 | onLoginClicked = { email, password -> 54 | if(email.isBlank() or password.isEmpty()) { 55 | Toast.makeText( 56 | context, 57 | R.string.empty_email_or_password_info, 58 | Toast.LENGTH_SHORT 59 | ).show() 60 | } else if(!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { 61 | Toast.makeText( 62 | context, 63 | R.string.invalid_email_address, 64 | Toast.LENGTH_SHORT 65 | ).show() 66 | } 67 | else { 68 | withContext(Dispatchers.Default) { 69 | state.mLoginSequence!!.login(email, password) 70 | } 71 | state.mLoginSequence!!.next(navController) 72 | } 73 | } 74 | ) 75 | } 76 | } 77 | // ------------------ TOTP --------------------------------------------------------------- 78 | composable(Routes.TOTP.route) { 79 | val onTotpClicked: suspend (value: String) -> Unit = 80 | { totp -> 81 | withContext(Dispatchers.Default) { 82 | state.mLoginSequence!!.totp(totp) 83 | } 84 | state.mLoginSequence!!.next(navController) 85 | } 86 | Totp(onBackClicked = { 87 | navController.popBackStack() 88 | }, onTotpClicked = onTotpClicked) 89 | } 90 | // ------------------ MAIN --------------------------------------------------------------- 91 | composable(Routes.Main.route) { 92 | Main(state, navController, requestPermissions, onSettingsClicked = { 93 | navController.navigate(Routes.Settings.route) 94 | }, onPollClicked) 95 | } 96 | // ------------------ BACKEND SELECTION -------------------------------------------------- 97 | composable(Routes.Backend.route) { 98 | BackendSelection(state = state, navController = navController) 99 | } 100 | // ------------------ ACCOUNT DETAILS ---------------------------------------------------- 101 | composable( 102 | Routes.Account.route, 103 | arguments = listOf(navArgument("email") { type = NavType.StringType }) 104 | ) { 105 | val accountEmail = it.arguments?.getString("email") 106 | if (accountEmail == null) { 107 | Log.e(navLogTag, "No account index selected, returning to main screen") 108 | navController.popBackStack(Routes.Main.route, false) 109 | } 110 | val account = state.account(accountEmail!!) 111 | if (account == null) { 112 | Log.e(navLogTag, "Account not found, return to main screen") 113 | navController.popBackStack(Routes.Main.route, false) 114 | } else { 115 | AccountInfo( 116 | account = account, 117 | onBackClicked = { 118 | navController.popBackStack(Routes.Main.route, false) 119 | }, 120 | onLogout = { 121 | withContext(Dispatchers.Default) { 122 | state.logout(accountEmail) 123 | } 124 | }, 125 | onLogin = { 126 | state.mLoginSequence = 127 | state.newLoginSequence(account.backend(), account.proxy()) 128 | navController.navigate(Routes.newLoginRoute(accountEmail)) 129 | }, 130 | onDelete = { 131 | withContext(Dispatchers.Default) { 132 | state.delete(accountEmail) 133 | } 134 | navController.popBackStack(Routes.Main.route, false) 135 | }, 136 | onProxyClicked = { 137 | navController.navigate(Routes.newAccountProxyRoute(accountEmail)) 138 | } 139 | ) 140 | } 141 | } 142 | // ------------------ SETTINGS ----------------------------------------------------------- 143 | composable(Routes.Settings.route) { 144 | Settings( 145 | state = state, 146 | onBackClicked = { 147 | navController.popBackStack() 148 | }, 149 | onPollIntervalUpdate = { interval -> 150 | state.setPollInterval(context, interval) 151 | }, 152 | onExportLogsClicked = onExportLogsClicked 153 | ) 154 | } 155 | // ------------------ PROXY LOGIN -------------------------------------------------------- 156 | composable( 157 | Routes.ProxyLogin.route, 158 | arguments = listOf(navArgument("name") { type = NavType.StringType }) 159 | ) { 160 | val args = it.arguments 161 | val backendName = args?.getString("name") 162 | if (backendName == null) { 163 | Log.e(navLogTag, "No backend name selected, returning to main screen") 164 | navController.popBackStack(Routes.Main.route, false) 165 | } else { 166 | ProxyScreen( 167 | onBackClicked = { 168 | navController.popBackStack() 169 | }, 170 | applyButtonText = stringResource(id = R.string.next), 171 | onApplyClicked = { proxy -> 172 | withContext(Dispatchers.Default) { 173 | if (proxy != null) { 174 | testProxy(proxy) 175 | } 176 | } 177 | state.mLoginSequence = state.newLoginSequence(backendName, proxy) 178 | navController.navigate(Routes.newLoginRoute(null)) 179 | }, 180 | proxy = null, 181 | isLoginRequest = true, 182 | ) 183 | } 184 | } 185 | // ------------------ PROXY SETTINGS ----------------------------------------------------- 186 | composable( 187 | Routes.ProxySettings.route, 188 | arguments = listOf(navArgument("email") { type = NavType.StringType }) 189 | ) { 190 | val accountEmail = it.arguments?.getString("email") 191 | if (accountEmail == null) { 192 | Log.e(navLogTag, "No account index selected, returning to main screen") 193 | navController.popBackStack(Routes.Main.route, false) 194 | } 195 | 196 | val account = state.account(accountEmail!!) 197 | if (account == null) { 198 | Log.e(navLogTag, "Account not found, return to main screen") 199 | navController.popBackStack(Routes.Main.route, false) 200 | } else { 201 | ProxyScreen( 202 | onBackClicked = { 203 | navController.popBackStack() 204 | }, 205 | applyButtonText = stringResource(id = R.string.apply), 206 | onApplyClicked = { proxy -> 207 | withContext(Dispatchers.Default) { 208 | account.setProxy(proxy) 209 | } 210 | navController.popBackStack() 211 | }, 212 | proxy = account.proxy(), 213 | isLoginRequest = false, 214 | ) 215 | } 216 | } 217 | // ------------------ PROTON CAPTCHA ----------------------------------------------------- 218 | composable(Routes.ProtonCaptcha.route) { 219 | ProtonCaptchaScreen( 220 | onBackClicked = { 221 | navController.popBackStack() 222 | }, 223 | onCaptchaSuccess = { 224 | withContext(Dispatchers.Default) { 225 | state.mLoginSequence!!.protonCaptcha(it) 226 | } 227 | state.mLoginSequence!!.next(navController) 228 | }, 229 | onCaptchaFail = { 230 | Toast.makeText(context, it, Toast.LENGTH_SHORT).show() 231 | navController.popBackStack() 232 | }, 233 | html = state.mLoginSequence!!.protonCaptchaHtml(), 234 | ) 235 | } 236 | } 237 | } 238 | 239 | 240 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/ProtonCaptcha.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import android.net.http.SslError 4 | import android.util.Log 5 | import android.view.ViewGroup 6 | import android.webkit.ConsoleMessage 7 | import android.webkit.JavascriptInterface 8 | import android.webkit.SslErrorHandler 9 | import android.webkit.WebChromeClient 10 | import android.webkit.WebResourceError 11 | import android.webkit.WebResourceRequest 12 | import android.webkit.WebResourceResponse 13 | import android.webkit.WebView 14 | import android.webkit.WebViewClient 15 | import androidx.compose.foundation.layout.Column 16 | import androidx.compose.foundation.layout.fillMaxSize 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.unit.dp 22 | import androidx.compose.ui.viewinterop.AndroidView 23 | import dev.lbeernaert.youhavemail.R 24 | import dev.lbeernaert.youhavemail.components.AsyncScreen 25 | import java.io.InputStream 26 | import java.text.SimpleDateFormat 27 | import java.util.Date 28 | import java.util.Locale 29 | 30 | 31 | const val PMCaptchaLogTag = "pm-captcha" 32 | 33 | @Composable 34 | fun ProtonCaptchaScreen( 35 | onBackClicked: () -> Unit, 36 | onCaptchaSuccess: suspend (String) -> Unit, 37 | onCaptchaFail: (String) -> Unit, 38 | html: String, 39 | ) { 40 | AsyncScreen( 41 | title = stringResource(id = R.string.captcha_request), 42 | onBackClicked = onBackClicked 43 | ) { _, runTask -> 44 | Column( 45 | modifier = Modifier 46 | .padding(0.dp) 47 | .fillMaxSize() 48 | ) { 49 | val taskLabel = stringResource(id = R.string.retry_login_with_captcha) 50 | AndroidView(factory = { 51 | WebView.setWebContentsDebuggingEnabled(true) 52 | WebView(it).apply { 53 | layoutParams = ViewGroup.LayoutParams( 54 | ViewGroup.LayoutParams.MATCH_PARENT, 55 | ViewGroup.LayoutParams.MATCH_PARENT 56 | ) 57 | webViewClient = CaptchaWebView( 58 | onCaptchaFail = onCaptchaFail, 59 | html = html, 60 | ) 61 | webChromeClient = object : WebChromeClient() { 62 | override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { 63 | Log.d(PMCaptchaLogTag, "${consoleMessage!!.message()}") 64 | return super.onConsoleMessage(consoleMessage) 65 | } 66 | } 67 | addJavascriptInterface(object { 68 | @JavascriptInterface 69 | fun receiveResponse(data: String) { 70 | Log.d(PMCaptchaLogTag, "Captcha solved") 71 | // JSON expected by the backend. 72 | val captchaResponse = 73 | "{\"hv_type\":\"Captcha\", \"hv_token\":\"${data}\"}" 74 | runTask(taskLabel) { 75 | onCaptchaSuccess(captchaResponse) 76 | } 77 | } 78 | 79 | @JavascriptInterface 80 | fun receiveExpiredResponse(data: String) { 81 | Log.d(PMCaptchaLogTag, "Received expired response:$data") 82 | onCaptchaFail("Captcha expired") 83 | } 84 | }, "AndroidInterface") 85 | settings.javaScriptEnabled = true 86 | settings.allowContentAccess = false 87 | settings.allowFileAccess = false 88 | loadUrl("https://mail.proton.me/core/v4/captcha") 89 | } 90 | }, update = { 91 | }) 92 | } 93 | } 94 | } 95 | 96 | 97 | class CaptchaWebView(var html: String, var onCaptchaFail: (String) -> Unit) : 98 | WebViewClient() { 99 | 100 | private val formatter: SimpleDateFormat = SimpleDateFormat("E, dd MMM yyyy kk:mm:ss", Locale.US) 101 | 102 | override fun onReceivedError( 103 | view: WebView?, 104 | request: WebResourceRequest?, 105 | error: WebResourceError? 106 | ) { 107 | super.onReceivedError(view, request, error) 108 | Log.e(PMCaptchaLogTag, "WebView Received Error: $error") 109 | onCaptchaFail("WebView Received Error: $error") 110 | } 111 | 112 | override fun onReceivedHttpError( 113 | view: WebView?, 114 | request: WebResourceRequest?, 115 | errorResponse: WebResourceResponse? 116 | ) { 117 | super.onReceivedHttpError(view, request, errorResponse) 118 | Log.e(PMCaptchaLogTag, "WebView Received HTTP Error: $errorResponse") 119 | onCaptchaFail("WebView Received HTTP Error: $errorResponse") 120 | } 121 | 122 | override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { 123 | super.onReceivedSslError(view, handler, error) 124 | Log.e(PMCaptchaLogTag, "WebView Received SSL Error: $error") 125 | onCaptchaFail("WebView Received SSL Error: $error") 126 | } 127 | 128 | override fun shouldInterceptRequest( 129 | view: WebView?, 130 | request: WebResourceRequest 131 | ): WebResourceResponse? { 132 | if (request.url.schemeSpecificPart.startsWith("//mail.proton.me")) { 133 | Log.d(PMCaptchaLogTag, "Intercepting request") 134 | var headers = HashMap() 135 | headers.put( 136 | "Access-Control-Allow-Origin", "*" 137 | ) 138 | headers.put("Connection", "close") 139 | headers.put("Date", "${formatter.format(Date())} GMT") 140 | headers.put("Content-Type", "text/html") 141 | headers.put("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS") 142 | headers.put("Access-Control-Max-Age", "600") 143 | headers.put("Access-Control-Allow-Credentials", "true") 144 | headers.put("Access-Control-Allow-Headers", "accept, authorization, Content-Type") 145 | headers.put("Via", "you-have-mail") 146 | 147 | var reader: InputStream = html.byteInputStream() 148 | return WebResourceResponse( 149 | "text/html", 150 | "UTF-8", 151 | 200, 152 | "OK", 153 | headers, 154 | reader, 155 | ) 156 | } 157 | return super.shouldInterceptRequest(view, request) 158 | } 159 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Routes.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | sealed class Routes(val route: String) { 4 | object Account : Routes("Account/{email}") 5 | object Login : Routes("Login?email={email}") 6 | object TOTP : Routes("TOTP") 7 | object Main : Routes("Main") 8 | object Backend : Routes("Backend") 9 | 10 | object Settings : Routes("Settings") 11 | object ProxyLogin : Routes("ProxyLogin/{name}") 12 | object ProxySettings : Routes("ProxySettings/{email}") 13 | object ProtonCaptcha : Routes("ProtonCaptcha") 14 | 15 | companion object { 16 | fun newLoginRoute(email: String?): String { 17 | if (email != null) { 18 | return "Login?email=$email" 19 | } 20 | 21 | return "Login" 22 | } 23 | 24 | fun newProxyLoginRoute(backendEmail: String): String { 25 | return "ProxyLogin/$backendEmail" 26 | } 27 | 28 | fun newAccountRoute(accountEmail: String): String { 29 | return "Account/$accountEmail" 30 | } 31 | 32 | fun newAccountProxyRoute(accountEmail: String): String { 33 | return "ProxySettings/$accountEmail" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Settings.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material.Divider 11 | import androidx.compose.material.DropdownMenu 12 | import androidx.compose.material.DropdownMenuItem 13 | import androidx.compose.material.ExperimentalMaterialApi 14 | import androidx.compose.material.ExposedDropdownMenuBox 15 | import androidx.compose.material.ExposedDropdownMenuDefaults 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.material.TextField 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.collectAsState 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableIntStateOf 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.geometry.Size 28 | import androidx.compose.ui.layout.onGloballyPositioned 29 | import androidx.compose.ui.platform.LocalDensity 30 | import androidx.compose.ui.res.stringResource 31 | import androidx.compose.ui.text.style.TextAlign 32 | import androidx.compose.ui.unit.dp 33 | import androidx.compose.ui.unit.toSize 34 | import dev.lbeernaert.youhavemail.R 35 | import dev.lbeernaert.youhavemail.app.State 36 | import dev.lbeernaert.youhavemail.components.ActionButton 37 | import dev.lbeernaert.youhavemail.components.AsyncScreen 38 | 39 | 40 | @OptIn(ExperimentalMaterialApi::class) 41 | @Composable 42 | fun Settings( 43 | state: State, 44 | onBackClicked: () -> Unit, 45 | onPollIntervalUpdate: (ULong) -> Unit, 46 | onExportLogsClicked: () -> Unit, 47 | ) { 48 | AsyncScreen( 49 | title = stringResource(id = R.string.settings), 50 | onBackClicked = onBackClicked 51 | ) { _, runTask -> 52 | 53 | val pollIntervalValue by state.getPollInterval().collectAsState() 54 | val updatingPollIntervalLabel = stringResource(id = R.string.update_poll_interval) 55 | 56 | Column( 57 | modifier = Modifier 58 | .padding(20.dp) 59 | .fillMaxSize() 60 | ) { 61 | val timeIntervals = 62 | listOf(30UL, 60UL, 120UL, 180UL, 300UL, 600UL, 900UL, 1200UL, 1800UL, 3600UL) 63 | var expanded by remember { mutableStateOf(false) } 64 | var selectedIndex by remember { mutableIntStateOf(0) } 65 | // textFieldWidth is used to assign to DropDownMenu the same width as TextField 66 | var textFieldWidth by remember { mutableStateOf(Size.Zero)} 67 | 68 | val onPollIntervalModified: () -> Unit = { 69 | runTask(updatingPollIntervalLabel) { 70 | onPollIntervalUpdate(timeIntervals[selectedIndex]) 71 | } 72 | } 73 | 74 | Text( 75 | text = stringResource(id = R.string.poll_interval), 76 | textAlign = TextAlign.Center, 77 | style = MaterialTheme.typography.h6, 78 | modifier = Modifier.fillMaxWidth() 79 | ) 80 | Spacer(modifier = Modifier.padding(5.dp)) 81 | Text( 82 | text = stringResource(id = R.string.poll_interval_desc), 83 | style = MaterialTheme.typography.subtitle1 84 | ) 85 | 86 | Spacer(modifier = Modifier.padding(5.dp)) 87 | 88 | ExposedDropdownMenuBox( 89 | modifier = Modifier 90 | .fillMaxWidth(), 91 | expanded = expanded, 92 | onExpandedChange = { 93 | expanded = !expanded 94 | } 95 | ) { 96 | TextField( 97 | modifier = Modifier 98 | .fillMaxWidth() 99 | .clickable { expanded = !expanded } 100 | .onGloballyPositioned { coordinates -> 101 | textFieldWidth = coordinates.size.toSize() 102 | }, 103 | readOnly = true, 104 | enabled = true, 105 | value = secondsToText(pollIntervalValue), 106 | onValueChange = { }, 107 | trailingIcon = { 108 | ExposedDropdownMenuDefaults.TrailingIcon( 109 | expanded = expanded, 110 | onIconClick = { expanded = true } 111 | ) 112 | }, 113 | colors = ExposedDropdownMenuDefaults.textFieldColors() 114 | ) 115 | DropdownMenu( 116 | modifier = Modifier 117 | .width(with(LocalDensity.current) { textFieldWidth.width.toDp() }), 118 | expanded = expanded, 119 | onDismissRequest = { 120 | expanded = false 121 | }, 122 | ) { 123 | timeIntervals.forEachIndexed { index, seconds -> 124 | DropdownMenuItem( 125 | modifier = Modifier 126 | .fillMaxWidth(), 127 | onClick = { 128 | expanded = false 129 | selectedIndex = index 130 | onPollIntervalModified() 131 | }, 132 | ) { 133 | Text(text = secondsToText(seconds)) 134 | } 135 | } 136 | } 137 | } 138 | 139 | Spacer(modifier = Modifier.padding(5.dp)) 140 | 141 | Divider() 142 | 143 | Spacer(modifier = Modifier.padding(5.dp)) 144 | 145 | ActionButton( 146 | text = stringResource(id = R.string.export_logs), 147 | onClick = onExportLogsClicked 148 | ) 149 | 150 | Spacer(modifier = Modifier.padding(5.dp)) 151 | } 152 | } 153 | } 154 | 155 | @Composable 156 | fun secondsToText(seconds: ULong): String { 157 | val secondStr = stringResource(id = R.string.second) 158 | val secondsStr = stringResource(id = R.string.seconds) 159 | val minuteStr = stringResource(id = R.string.minute) 160 | val minutesStr = stringResource(id = R.string.minutes) 161 | return if(seconds == 1UL) { 162 | "$seconds $secondStr" 163 | } else if (seconds < 60UL) { 164 | "$seconds $secondsStr" 165 | } else if((seconds == 60UL) ) { 166 | "${(seconds / 60UL)} $minuteStr" 167 | } else { 168 | "${(seconds / 60UL)} $minutesStr" 169 | } 170 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/screens/Totp.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.screens 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.text.KeyboardActions 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material.Text 7 | import androidx.compose.material.TextField 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.input.ImeAction 15 | import androidx.compose.ui.text.input.KeyboardType 16 | import androidx.compose.ui.text.input.PasswordVisualTransformation 17 | import androidx.compose.ui.text.input.TextFieldValue 18 | import androidx.compose.ui.unit.dp 19 | import dev.lbeernaert.youhavemail.R 20 | import dev.lbeernaert.youhavemail.components.ActionButton 21 | import dev.lbeernaert.youhavemail.components.AsyncScreen 22 | 23 | @Composable 24 | fun Totp( 25 | onBackClicked: () -> Unit, 26 | onTotpClicked: suspend (value: String) -> Unit 27 | ) { 28 | val totp = remember { mutableStateOf(TextFieldValue()) } 29 | val totpBackgroundLabel = stringResource(id = R.string.submitting_totp) 30 | 31 | AsyncScreen( 32 | title = stringResource(id = R.string.totp_title), 33 | onBackClicked = onBackClicked 34 | ) { padding, runTask -> 35 | val onClick: () -> Unit = { 36 | runTask(totpBackgroundLabel) { 37 | onTotpClicked(totp.value.text) 38 | } 39 | } 40 | 41 | Column( 42 | modifier = Modifier 43 | .padding(padding) 44 | .padding(20.dp) 45 | .fillMaxSize(), 46 | verticalArrangement = Arrangement.Center, 47 | horizontalAlignment = Alignment.CenterHorizontally, 48 | 49 | ) { 50 | Text(text = stringResource(R.string.totp_request)) 51 | 52 | Spacer(modifier = Modifier.height(20.dp)) 53 | 54 | TextField( 55 | modifier = Modifier.fillMaxWidth(), 56 | label = { Text(text = "TOTP") }, 57 | value = totp.value, 58 | singleLine = true, 59 | visualTransformation = PasswordVisualTransformation(), 60 | keyboardOptions = KeyboardOptions( 61 | keyboardType = KeyboardType.Number, 62 | imeAction = ImeAction.Done 63 | ), 64 | onValueChange = { totp.value = it }, 65 | keyboardActions = KeyboardActions(onDone = { 66 | onClick() 67 | }) 68 | ) 69 | 70 | Spacer(modifier = Modifier.height(20.dp)) 71 | 72 | ActionButton(text = stringResource(id = R.string.submit), onClick = onClick) 73 | } 74 | 75 | 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/ui/Autofill.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.ExperimentalComposeUiApi 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.autofill.AutofillNode 8 | import androidx.compose.ui.autofill.AutofillType 9 | import androidx.compose.ui.focus.onFocusChanged 10 | import androidx.compose.ui.layout.boundsInWindow 11 | import androidx.compose.ui.layout.onGloballyPositioned 12 | import androidx.compose.ui.platform.LocalAutofill 13 | import androidx.compose.ui.platform.LocalAutofillTree 14 | 15 | // This file is adapted from https://medium.com/@bagadeshrp/compose-ui-textfield-autofill-6e2ac434e380 16 | 17 | // The autofill modifier internally adds two modifiers: 18 | // one to setup the layout, and one to listen for focus events. 19 | @OptIn(ExperimentalComposeUiApi::class) 20 | fun Modifier.autofill(handler: AutoFillHandler): Modifier { 21 | return this.then( 22 | onGloballyPositioned { 23 | handler.autoFillNode.boundingBox = it.boundsInWindow() 24 | } 25 | ).then( 26 | onFocusChanged { 27 | if (it.isFocused) { 28 | handler.request() 29 | } else { 30 | handler.cancel() 31 | } 32 | } 33 | ) 34 | } 35 | 36 | @OptIn(ExperimentalComposeUiApi::class) 37 | @Composable 38 | fun AutoFillRequestHandler( 39 | autofillTypes: List = listOf(), 40 | onFill: (String) -> Unit, 41 | ): AutoFillHandler { 42 | val autoFillNode = remember { 43 | AutofillNode( 44 | autofillTypes = autofillTypes, 45 | onFill = { onFill(it) } 46 | ) 47 | } 48 | val autofill = LocalAutofill.current 49 | LocalAutofillTree.current += autoFillNode 50 | return remember { 51 | object : AutoFillHandler { 52 | override val autoFillNode: AutofillNode 53 | get() = autoFillNode 54 | 55 | override fun request() { 56 | autofill?.requestAutofillForNode(autofillNode = autoFillNode) 57 | } 58 | 59 | override fun cancel() { 60 | autofill?.cancelAutofillForNode(autofillNode = autoFillNode) 61 | } 62 | } 63 | } 64 | } 65 | 66 | @OptIn(ExperimentalComposeUiApi::class) 67 | interface AutoFillHandler { 68 | val autoFillNode: AutofillNode 69 | fun request() 70 | fun cancel() 71 | } 72 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Purple200, 11 | primaryVariant = Purple700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Purple500, 17 | primaryVariant = Purple700, 18 | secondary = Teal200 19 | 20 | /* Other default colors to override 21 | background = Color.White, 22 | surface = Color.White, 23 | onPrimary = Color.White, 24 | onSecondary = Color.Black, 25 | onBackground = Color.Black, 26 | onSurface = Color.Black, 27 | */ 28 | ) 29 | 30 | @Composable 31 | fun YouHaveMailTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/java/dev/lbeernaert/youhavemail/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_alert.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_err.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-hdpi/ic_stat_sync.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_alert.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_err.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-mdpi/ic_stat_sync.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_alert.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_err.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xhdpi/ic_stat_sync.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_alert.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_err.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxhdpi/ic_stat_sync.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_alert.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_err.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/drawable-xxxhdpi/ic_stat_sync.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/drawable/ic_base.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #5F3DDC 4 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | You Have Mail 3 | Connecting to Service... 4 | No active accounts. 5 | Add Account 6 | Username 7 | Password 8 | Log into %1$s 9 | Login 10 | Submit 11 | TOTP 12 | Please type in TOTP verification code 13 | Account Successfully added 14 | Select Backend 15 | Logging into %1$s 16 | Status 17 | Status: %1$s 18 | Result: %1$s 19 | Online 20 | Error 21 | Offline 22 | Logged Out 23 | Submitting TOTP 24 | Logout 25 | Logging Out 26 | Delete Account 27 | Account Details 28 | Notification Permission 29 | 30 | Notification permission is required, to show notification. 31 | 32 | 33 | Notification permission is required, Please allow notification permission from settings. 34 | 35 | Cancel 36 | Ok 37 | You Have Mail Alert 38 | %1$s has %2$d new messages 39 | %1$s encountered an error "%2$s" 40 | Settings 41 | Poll Interval 42 | Second 43 | Seconds 44 | Minute 45 | Minutes 46 | Updating Poll Interval 47 | 48 | Frequency at which the observer should poll the accounts. A higher poll interval can help 49 | reduce battery usage. 50 | 51 | Use Proxy 52 | Applying Proxy Settings... 53 | IP Address 54 | IP Port 55 | Proxy User 56 | Proxy Protocol 57 | Proxy Settings 58 | Proxy User Password 59 | Authentication 60 | This step is optional. If you don\'t normally use a Proxy to connect or don\'t know what a Proxy is, click Next to continue. 61 | Next 62 | Apply 63 | Activity 64 | Captcha Verification 65 | Retrying Login after Captcha Solve 66 | Export Logs 67 | Logs successfully exported 68 | Failed to export logs: %1$s 69 | Email and password fields cannot be empty 70 | E-mail address seems to be invalid. Verify if it is correctly typed. 71 | Service failed to initialize. 72 | Message marked as read 73 | Failed to mark message as read 74 | Message moved to Trash 75 | Failed to move message to Trash 76 | Message moved to Spam 77 | Failed to move message to Spam 78 | Action Succeeded 79 | Action Failed 80 | Mark Read 81 | Trash 82 | Spam 83 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /you-have-mail-android/app/src/test/java/dev/lbeernaert/youhavemail/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package dev.lbeernaert.youhavemail 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /you-have-mail-android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | compose_ui_version = '1.6.8' 4 | versions_work = "2.9.0" 5 | } 6 | }// Top-level build file where you can add configuration options common to all sub-projects/modules. 7 | 8 | plugins { 9 | id 'com.android.application' version '8.1.4' apply false 10 | id 'com.android.library' version '8.1.4' apply false 11 | id 'org.jetbrains.kotlin.android' version '1.8.0' apply false 12 | id "org.mozilla.rust-android-gradle.rust-android" version "0.9.5" 13 | } -------------------------------------------------------------------------------- /you-have-mail-android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | android.defaults.buildfeatures.buildconfig=true 25 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /you-have-mail-android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanderBB/you-have-mail/10dffc986fe50cd594fbc0cdefe6eab9327944a9/you-have-mail-android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /you-have-mail-android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Mar 18 11:30:03 CET 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /you-have-mail-android/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /you-have-mail-android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /you-have-mail-android/icon_inkscape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 90 | -------------------------------------------------------------------------------- /you-have-mail-android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "YouHaveMail" 16 | include ':app' 17 | -------------------------------------------------------------------------------- /you-have-mail-mobile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "you-have-mail-mobile" 3 | version = "0.14.0" 4 | edition = "2024" 5 | authors = ["Leander Beernaert "] 6 | license = "AGPL-3.0-only" 7 | categories = ["email", "api-bindings"] 8 | description = "You-Have-Mail mobile API bindings" 9 | rust-version= "1.85.0" 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | name = "youhavemail" 14 | 15 | [dependencies] 16 | uniffi = { version = "0.29.0", features = ["cli"] } 17 | you-have-mail-common = { git = "https://github.com/LeanderBB/you-have-mail-common" } 18 | #you-have-mail-common = { path = "../../you-have-mail-common/youhavemail" } 19 | thiserror = "2" 20 | parking_lot = "0.12" 21 | tracing = "0.1" 22 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 23 | tracing-appender = "0.2.3" 24 | chrono = "0.4" 25 | sqlite-watcher = "0.4.1" 26 | 27 | [dev-dependencies] 28 | tempdir = "0.3.7" 29 | 30 | [lints.clippy] 31 | pedantic = "deny" 32 | 33 | [[bin]] 34 | # This can be whatever name makes sense for your project, but the rest of this tutorial assumes uniffi-bindgen. 35 | name = "uniffi-bindgen" 36 | path = "uniffi-bindgen/uniffi-bindgen.rs" 37 | 38 | 39 | [profile.release] 40 | strip = true 41 | opt-level = "z" # Optimize for size. 42 | lto = true 43 | codegen-units = 1 44 | panic = "abort" 45 | -------------------------------------------------------------------------------- /you-have-mail-mobile/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85.0" 3 | profile = "default" 4 | targets = [ 5 | "aarch64-linux-android", 6 | "armv7-linux-androideabi", 7 | "x86_64-linux-android", 8 | ] 9 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/account.rs: -------------------------------------------------------------------------------- 1 | use crate::events::Event; 2 | use chrono::{DateTime, Local}; 3 | use std::fmt::Debug; 4 | use std::sync::Arc; 5 | use you_have_mail_common as yhm; 6 | 7 | /// An account in the system. 8 | #[derive(uniffi::Object)] 9 | pub struct Account { 10 | account: yhm::state::Account, 11 | } 12 | 13 | #[uniffi::export] 14 | impl Account { 15 | pub fn email(&self) -> String { 16 | self.account.email().to_owned() 17 | } 18 | 19 | pub fn backend(&self) -> String { 20 | self.account.backend().to_string() 21 | } 22 | 23 | pub fn set_proxy( 24 | &self, 25 | proxy: Option, 26 | ) -> Result<(), crate::yhm::YhmError> { 27 | let proxy = proxy.map(Into::into); 28 | Ok(self 29 | .account 30 | .set_proxy(proxy.as_ref()) 31 | .map_err(yhm::yhm::Error::from)?) 32 | } 33 | 34 | pub fn proxy(&self) -> Result, crate::yhm::YhmError> { 35 | Ok(self 36 | .account 37 | .proxy() 38 | .map(|v| v.map(Into::into)) 39 | .map_err(yhm::yhm::Error::from)?) 40 | } 41 | 42 | pub fn is_logged_out(&self) -> Result { 43 | Ok(self 44 | .account 45 | .is_logged_out() 46 | .map_err(yhm::yhm::Error::from)?) 47 | } 48 | 49 | pub fn last_event(&self) -> Result, crate::yhm::YhmError> { 50 | Ok(self 51 | .account 52 | .last_event() 53 | .map_err(yhm::yhm::Error::from)? 54 | .map(Event::from)) 55 | } 56 | 57 | pub fn last_poll(&self) -> Option { 58 | self.account.last_poll().map(|dt| { 59 | let local = DateTime::::from(*dt); 60 | format!("{}", local.format("%Y/%m/%d %H:%M")) 61 | }) 62 | } 63 | } 64 | 65 | impl Account { 66 | pub fn new(account: yhm::state::Account) -> Self { 67 | Self { account } 68 | } 69 | } 70 | 71 | #[uniffi::export(with_foreign)] 72 | pub trait AccountWatcher: Send + Sync + Debug { 73 | fn on_accounts_updated(&self, accounts: Vec>); 74 | } 75 | pub(crate) struct FFIAccountTableObserver(pub Arc); 76 | 77 | impl you_have_mail_common::state::AccountWatcher for FFIAccountTableObserver { 78 | fn on_accounts_updated(&self, accounts: Vec) { 79 | self.0.on_accounts_updated( 80 | accounts 81 | .into_iter() 82 | .map(|v| Arc::new(Account::new(v))) 83 | .collect(), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/android.rs: -------------------------------------------------------------------------------- 1 | use you_have_mail_common::exports::rusqlite::{Error as DBError, params}; 2 | 3 | /// Notification ids associated with a given account. 4 | #[derive(Debug, Eq, PartialEq, Copy, Clone, uniffi::Record)] 5 | pub struct AccountNotificationIds { 6 | /// For notification grouping. 7 | pub group: i32, 8 | /// For status updates, such as logout. 9 | pub status: i32, 10 | /// For error reporting. 11 | pub error: i32, 12 | } 13 | 14 | /// Extension trait to store additional state for the android application. 15 | pub trait StateExtension { 16 | /// Create the android specific tables. 17 | /// 18 | /// # Errors 19 | /// 20 | /// Returns error if the queries failed. 21 | fn android_init_tables(&self) -> Result<(), DBError>; 22 | 23 | /// Get the next unique notification id for the given user. 24 | /// 25 | /// # Errors 26 | /// 27 | /// Returns error if the queries failed. 28 | fn android_next_mail_notification_id(&self, email: &str) -> Result; 29 | 30 | /// Get or create the 3 stable notification ids for a user with `email`. 31 | /// 32 | /// # Errors 33 | /// 34 | /// Returns error if the query failed. 35 | fn android_get_or_create_notification_ids( 36 | &self, 37 | email: &str, 38 | ) -> Result; 39 | } 40 | 41 | impl StateExtension for you_have_mail_common::state::State { 42 | fn android_init_tables(&self) -> Result<(), DBError> { 43 | self.db_write(|tx| { 44 | tx.execute( 45 | r" 46 | CREATE TABLE IF NOT EXISTS android_notification_ids( 47 | email TEXT PRIMARY KEY, 48 | group_id INTEGER NOT NULL, 49 | status_id INTEGER NOT NULL, 50 | error_id INTEGER NOT NULL 51 | ) 52 | ", 53 | params![], 54 | )?; 55 | 56 | tx.execute( 57 | r" 58 | CREATE TABLE IF NOT EXISTS android_next_mail_notification_id( 59 | id INTEGER PRIMARY KEY AUTOINCREMENT, 60 | email TEXT NOT NULL UNIQUE 61 | ) 62 | ", 63 | params![], 64 | )?; 65 | Ok(()) 66 | }) 67 | } 68 | 69 | fn android_next_mail_notification_id(&self, email: &str) -> Result { 70 | self.db_write(|tx| { 71 | tx.query_row( 72 | "INSERT OR REPLACE INTO android_next_mail_notification_id (email) VALUES (?) RETURNING id", 73 | params![email], 74 | |r| r.get(0), 75 | ).map(|v:u64| { 76 | i32::try_from(v % (i32::MAX as u64)).expect("Should never fail") 77 | }) 78 | }) 79 | } 80 | 81 | fn android_get_or_create_notification_ids( 82 | &self, 83 | email: &str, 84 | ) -> Result { 85 | self.db_write(|tx| { 86 | tx.query_row( 87 | r" 88 | WITH cte (gid) AS ( 89 | SELECT IFNULL(MAX(group_id)+3,100) FROM android_notification_ids 90 | ) 91 | INSERT INTO android_notification_ids ( 92 | email, group_id, status_id, error_id 93 | ) VALUES (?, 94 | (SELECT cte.gid FROM cte), 95 | (SELECT cte.gid+1 FROM cte), 96 | (SELECT cte.gid+2 FROM cte) 97 | ) 98 | ON CONFLICT (email) DO UPDATE SET email=email 99 | RETURNING group_id, status_id, error_id 100 | ", 101 | params![email], 102 | |r| { 103 | Ok(AccountNotificationIds { 104 | group: r.get(0)?, 105 | status: r.get(1)?, 106 | error: r.get(2)?, 107 | }) 108 | }, 109 | ) 110 | }) 111 | } 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | use super::*; 117 | use sqlite_watcher::watcher::Watcher; 118 | use std::sync::Arc; 119 | use tempdir::TempDir; 120 | use you_have_mail_common::encryption::Key; 121 | use you_have_mail_common::state::State; 122 | 123 | #[test] 124 | fn check_get_or_create_notification_ids() { 125 | let (state, _tmp_dir) = new_state(); 126 | 127 | const START_GROUP_ID: i32 = 100; 128 | 129 | // Insert first time should start with START_GROUP_ID 130 | let ids = state.android_get_or_create_notification_ids("foo").unwrap(); 131 | 132 | assert_eq!(ids.group, START_GROUP_ID); 133 | assert_eq!(ids.status, START_GROUP_ID + 1); 134 | assert_eq!(ids.error, START_GROUP_ID + 2); 135 | 136 | // Insert the second time, no changes 137 | let ids2 = state.android_get_or_create_notification_ids("foo").unwrap(); 138 | assert_eq!(ids, ids2); 139 | 140 | // Other account 141 | let ids3 = state.android_get_or_create_notification_ids("bar").unwrap(); 142 | assert_eq!(ids3.group, START_GROUP_ID + 3); 143 | assert_eq!(ids3.status, START_GROUP_ID + 4); 144 | assert_eq!(ids3.error, START_GROUP_ID + 5); 145 | } 146 | 147 | #[test] 148 | fn check_next_mail_notification_id() { 149 | let (state, _tmp_dir) = new_state(); 150 | 151 | let id_foo_1 = state.android_next_mail_notification_id("foo").unwrap(); 152 | let id_foo_2 = state.android_next_mail_notification_id("foo").unwrap(); 153 | let id_bar_1 = state.android_next_mail_notification_id("bar").unwrap(); 154 | let id_foo_3 = state.android_next_mail_notification_id("foo").unwrap(); 155 | let id_bar_2 = state.android_next_mail_notification_id("bar").unwrap(); 156 | assert!(id_foo_1 < id_foo_2); 157 | assert!(id_foo_2 < id_bar_1); 158 | assert!(id_bar_1 < id_foo_3); 159 | assert!(id_foo_3 < id_bar_2); 160 | } 161 | 162 | fn new_state() -> (Arc, TempDir) { 163 | let tmp_dir = TempDir::new("yhm-android-test").unwrap(); 164 | let db_path = tmp_dir.path().join("sqlite.db"); 165 | let watcher = Watcher::new().unwrap(); 166 | let state = State::without_init(db_path, Key::new(), watcher); 167 | state.android_init_tables().unwrap(); 168 | 169 | (state, tmp_dir) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod proton; 2 | 3 | use std::sync::Arc; 4 | use you_have_mail_common as yhm; 5 | 6 | /// Represents a backend implementation. 7 | #[derive(uniffi::Object)] 8 | pub struct Backend(pub Arc); 9 | 10 | #[uniffi::export] 11 | impl Backend { 12 | /// Get the backend name. 13 | #[must_use] 14 | pub fn name(&self) -> String { 15 | self.0.name().to_owned() 16 | } 17 | 18 | /// Get a short description about this backend. 19 | #[must_use] 20 | pub fn description(&self) -> String { 21 | self.0.description().to_owned() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/backend/proton.rs: -------------------------------------------------------------------------------- 1 | use crate::yhm::YhmError; 2 | use parking_lot::Mutex; 3 | use std::fmt::{Display, Formatter}; 4 | use yhm::backend::proton::proton_api; 5 | use you_have_mail_common as yhm; 6 | use you_have_mail_common::backend::proton::proton_api::domain::human_verification::{ 7 | HumanVerification, LoginData, VerificationType, 8 | }; 9 | use you_have_mail_common::backend::proton::proton_api::requests::GetCaptchaRequest; 10 | use you_have_mail_common::yhm::IntoAccount; 11 | 12 | #[derive(Debug, uniffi::Enum)] 13 | pub enum ProtonHumanVerificationType { 14 | Captcha, 15 | Email, 16 | Sms, 17 | } 18 | 19 | impl Display for ProtonHumanVerificationType { 20 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 21 | match self { 22 | ProtonHumanVerificationType::Captcha => { 23 | write!(f, "Captcha") 24 | } 25 | ProtonHumanVerificationType::Email => { 26 | write!(f, "Email") 27 | } 28 | ProtonHumanVerificationType::Sms => { 29 | write!(f, "Sms") 30 | } 31 | } 32 | } 33 | } 34 | 35 | impl From for ProtonHumanVerificationType { 36 | fn from(value: VerificationType) -> Self { 37 | match value { 38 | VerificationType::Captcha => Self::Captcha, 39 | VerificationType::Email => Self::Email, 40 | VerificationType::Sms => Self::Sms, 41 | } 42 | } 43 | } 44 | 45 | impl From for VerificationType { 46 | fn from(value: ProtonHumanVerificationType) -> Self { 47 | match value { 48 | ProtonHumanVerificationType::Captcha => Self::Captcha, 49 | ProtonHumanVerificationType::Email => Self::Sms, 50 | ProtonHumanVerificationType::Sms => Self::Email, 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug, uniffi::Record)] 56 | pub struct ProtonHumanVerification { 57 | /// Types of supported verification. 58 | pub methods: Vec, 59 | /// Token for the verification request. 60 | pub token: String, 61 | } 62 | 63 | impl From for ProtonHumanVerification { 64 | fn from(value: HumanVerification) -> Self { 65 | Self { 66 | methods: value 67 | .methods 68 | .into_iter() 69 | .map(ProtonHumanVerificationType::from) 70 | .collect(), 71 | token: value.token, 72 | } 73 | } 74 | } 75 | 76 | #[derive(Debug, thiserror::Error, uniffi::Error)] 77 | pub enum ProtonLoginError { 78 | #[error("API: {0}")] 79 | Api(String), 80 | #[error("Http: {0}")] 81 | Http(String), 82 | #[error("Can not perform operation in the current state")] 83 | InvalidState, 84 | #[error("Server SRP proof verification failed: {0}")] 85 | SRPServerProof(String), 86 | #[error("SRP: {0}")] 87 | SRP(String), 88 | #[error("Account 2FA method ({0}) is not supported")] 89 | Unsupported2FA(String), 90 | #[error("Invalid Human Verification Data ")] 91 | InvalidHumanVerificationData, 92 | #[error("Human Verification Required'")] 93 | HumanVerificationRequired(ProtonHumanVerification), 94 | #[error("Unsupported Human Verification:{0}")] 95 | HumanVerificationTypeNotSupported(ProtonHumanVerificationType), 96 | #[error("Auth Store:{0}")] 97 | AuthStore(String), 98 | #[error("The object has become invalid")] 99 | Invalid, 100 | #[error("Create Account: {0}")] 101 | CreateAccount(#[from] YhmError), 102 | } 103 | 104 | impl From for ProtonLoginError { 105 | fn from(value: proton_api::login::Error) -> Self { 106 | use proton_api::login::Error; 107 | match value { 108 | Error::Api(e) => Self::Api(e.to_string()), 109 | Error::Http(e) => Self::Http(e.to_string()), 110 | Error::InvalidState => Self::InvalidState, 111 | Error::SRPServerProof(e) => Self::SRPServerProof(e), 112 | Error::SRP(e) => Self::SRP(e.to_string()), 113 | Error::Unsupported2FA(e) => Self::Unsupported2FA(e.to_string()), 114 | Error::HumanVerificationRequired(hv) => Self::HumanVerificationRequired(hv.into()), 115 | Error::HumanVerificationTypeNotSupported(hv) => { 116 | Self::HumanVerificationTypeNotSupported(hv.into()) 117 | } 118 | Error::AuthStore(e) => Self::AuthStore(e.to_string()), 119 | } 120 | } 121 | } 122 | 123 | impl From for ProtonLoginError { 124 | fn from(value: yhm::http::Error) -> Self { 125 | Self::Http(value.to_string()) 126 | } 127 | } 128 | 129 | /// Guides the user through the process of integrating with a Proton account. 130 | #[derive(uniffi::Object)] 131 | 132 | pub struct ProtonLoginSequence { 133 | sequence: Mutex>, 134 | } 135 | 136 | #[uniffi::export] 137 | impl ProtonLoginSequence { 138 | /// Create new instance. 139 | /// 140 | /// # Errors 141 | /// 142 | /// Returns error if the client fails to build. 143 | #[uniffi::constructor] 144 | pub fn new(proxy: Option) -> Result { 145 | let sequence = yhm::backend::proton::Backend::login_sequence(proxy.map(Into::into))?; 146 | 147 | Ok(Self { 148 | sequence: Mutex::new(Some(sequence)), 149 | }) 150 | } 151 | 152 | /// Check whether the account is logged in. 153 | /// 154 | /// # Errors 155 | /// 156 | /// Returns error if the query failed. 157 | pub fn is_logged_in(&self) -> Result { 158 | let guard = self.sequence.lock(); 159 | let Some(sequence) = &*guard else { 160 | return Err(ProtonLoginError::Invalid); 161 | }; 162 | 163 | Ok(sequence.is_logged_in()) 164 | } 165 | 166 | /// Check whether the account is logged out. 167 | /// 168 | /// # Errors 169 | /// 170 | /// Returns error if the query failed. 171 | pub fn is_logged_out(&self) -> Result { 172 | let guard = self.sequence.lock(); 173 | let Some(sequence) = &*guard else { 174 | return Err(ProtonLoginError::Invalid); 175 | }; 176 | 177 | Ok(sequence.is_logged_out()) 178 | } 179 | 180 | /// Check whether the account is awaiting two factor authentication. 181 | /// 182 | /// # Errors 183 | /// 184 | /// Returns error if the query failed. 185 | pub fn is_awaiting_totp(&self) -> Result { 186 | let guard = self.sequence.lock(); 187 | let Some(sequence) = &*guard else { 188 | return Err(ProtonLoginError::Invalid); 189 | }; 190 | 191 | Ok(sequence.is_awaiting_totp()) 192 | } 193 | 194 | /// Login into account with `email` and `password`. 195 | /// 196 | /// Human verification data can be passed in with `human_verification`. 197 | /// 198 | /// # Errors 199 | /// 200 | /// Returns error if the request failed. 201 | pub fn login( 202 | &self, 203 | email: &str, 204 | password: &str, 205 | human_verification: Option, 206 | ) -> Result<(), ProtonLoginError> { 207 | let login_data = if let Some(hv_data) = human_verification { 208 | Some(LoginData::from_webview_string(&hv_data).map_err(|e| { 209 | tracing::error!("Failed to deserialize hv data: {e}"); 210 | ProtonLoginError::InvalidHumanVerificationData 211 | })?) 212 | } else { 213 | None 214 | }; 215 | 216 | let mut guard = self.sequence.lock(); 217 | let Some(sequence) = &mut *guard else { 218 | return Err(ProtonLoginError::Invalid); 219 | }; 220 | 221 | Ok(sequence.login(email, password, login_data.as_ref())?) 222 | } 223 | 224 | /// Submit `totp` code. 225 | /// 226 | /// # Errors 227 | /// 228 | /// Returns error if the request failed. 229 | pub fn submit_totp(&self, code: &str) -> Result<(), ProtonLoginError> { 230 | let mut guard = self.sequence.lock(); 231 | let Some(sequence) = &mut *guard else { 232 | return Err(ProtonLoginError::Invalid); 233 | }; 234 | 235 | Ok(sequence.submit_totp(code)?) 236 | } 237 | 238 | /// Convert into a usable account. 239 | /// 240 | /// Note that after this operation the object becomes invalid, whether we succeed 241 | /// or fail. 242 | /// 243 | /// # Errors 244 | /// 245 | /// Returns error if the operation failed. 246 | pub fn create_account(&self, yhm: &crate::yhm::Yhm) -> Result<(), ProtonLoginError> { 247 | let mut guard = self.sequence.lock(); 248 | let Some(sequence) = guard.take() else { 249 | return Err(ProtonLoginError::Invalid); 250 | }; 251 | 252 | Ok(sequence 253 | .into_account(yhm.instance()) 254 | .map_err(YhmError::from)?) 255 | } 256 | 257 | /// Get the captcha html code for a given token. 258 | /// 259 | /// # Errors 260 | /// 261 | /// Returns error if request failed. 262 | pub fn captcha(&self, token: &str) -> Result { 263 | let mut guard = self.sequence.lock(); 264 | let Some(sequence) = guard.take() else { 265 | return Err(ProtonLoginError::Invalid); 266 | }; 267 | 268 | Ok(sequence 269 | .session() 270 | .execute(GetCaptchaRequest::new(token, false))?) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/events.rs: -------------------------------------------------------------------------------- 1 | use you_have_mail_common as yhm; 2 | 3 | /// Action that can be taken on message. 4 | pub struct Action(String); 5 | 6 | uniffi::custom_newtype!(Action, String); 7 | 8 | impl From for Action { 9 | fn from(action: yhm::backend::Action) -> Self { 10 | Self(action.take()) 11 | } 12 | } 13 | 14 | impl From for yhm::backend::Action { 15 | fn from(action: Action) -> Self { 16 | yhm::backend::Action::with(action.0) 17 | } 18 | } 19 | 20 | /// Possible events 21 | #[derive(uniffi::Record)] 22 | pub struct NewEmail { 23 | pub sender: String, 24 | pub subject: String, 25 | pub move_to_trash_action: Option, 26 | pub move_to_spam_action: Option, 27 | pub mark_as_read_action: Option, 28 | } 29 | #[derive(uniffi::Enum)] 30 | pub enum Event { 31 | /// One or more emails has arrived. 32 | Email { 33 | email: String, 34 | backend: String, 35 | emails: Vec, 36 | }, 37 | /// Account has been logged out. 38 | LoggedOut(String), 39 | /// Account servers are not reachable. 40 | Offline(String), 41 | /// General error occurred. 42 | Error(String, String), 43 | } 44 | 45 | impl From for Event { 46 | fn from(value: yhm::events::Event) -> Self { 47 | match value { 48 | yhm::events::Event::Error(email, e) => Self::Error(email, e.to_string()), 49 | yhm::events::Event::Offline(email) => Self::Offline(email), 50 | yhm::events::Event::LoggedOut(email) => Self::LoggedOut(email), 51 | yhm::events::Event::NewEmail { 52 | email, 53 | backend, 54 | emails, 55 | } => Self::Email { 56 | email, 57 | backend, 58 | emails: emails 59 | .into_iter() 60 | .map(|v| NewEmail { 61 | sender: v.sender, 62 | subject: v.subject, 63 | mark_as_read_action: v.mark_as_read_action.map(Into::into), 64 | move_to_trash_action: v.move_to_trash_action.map(Into::into), 65 | move_to_spam_action: v.move_to_spam_action.map(Into::into), 66 | }) 67 | .collect(), 68 | }, 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions)] // hard to enforce over binding layer. 2 | 3 | //! You Have Mail bindings for mobile platforms. 4 | pub mod proxy; 5 | 6 | mod account; 7 | pub mod android; 8 | pub mod backend; 9 | mod events; 10 | mod logging; 11 | mod watcher; 12 | pub mod yhm; 13 | 14 | uniffi::setup_scaffolding!(); 15 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/logging.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::path::PathBuf; 3 | use std::sync::Once; 4 | use tracing::level_filters::LevelFilter; 5 | use tracing_subscriber::EnvFilter; 6 | use uniffi::export; 7 | 8 | static INIT_LOG_ONCE: Once = Once::new(); 9 | 10 | /// Initialize the log at `filepath`. 11 | #[export] 12 | pub fn init_log(filepath: String) -> Option { 13 | let mut result = None; 14 | 15 | let result_ref = &mut result; 16 | INIT_LOG_ONCE.call_once(move || { 17 | *result_ref = match init_log_fn(filepath.into()) { 18 | Ok(()) => None, 19 | Err(e) => Some(e.to_string()), 20 | } 21 | }); 22 | 23 | result 24 | } 25 | 26 | fn init_log_fn(path: PathBuf) -> Result<(), Box> { 27 | let appender = tracing_appender::rolling::never(path, "yhm.log"); 28 | let filter = EnvFilter::builder() 29 | .parse_lossy("info,you_have_mail_common=debug,http=debug,proton_api=debug"); 30 | tracing_subscriber::FmtSubscriber::builder() 31 | .with_ansi(false) 32 | .with_writer(appender) 33 | .with_max_level(LevelFilter::DEBUG) 34 | .with_env_filter(filter) 35 | .try_init() 36 | } 37 | 38 | #[export] 39 | fn yhm_log_info(text: &str) { 40 | tracing::info!("[APP] {text}"); 41 | } 42 | 43 | #[export] 44 | fn yhm_log_error(text: &str) { 45 | tracing::error!("[APP] {text}"); 46 | } 47 | 48 | #[export] 49 | fn yhm_log_warn(text: &str) { 50 | tracing::warn!("[APP] {text}"); 51 | } 52 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/proxy.rs: -------------------------------------------------------------------------------- 1 | use crate::yhm::YhmError; 2 | use tracing::error; 3 | use you_have_mail_common as yhm; 4 | use you_have_mail_common::backend::proton::proton_api::client::ProtonExtension; 5 | use you_have_mail_common::backend::proton::proton_api::requests::Ping; 6 | use you_have_mail_common::secrecy::{ExposeSecret, SecretString}; 7 | 8 | /// Proxy protocol. 9 | #[derive(uniffi::Enum)] 10 | pub enum Protocol { 11 | Http, 12 | Socks5, 13 | } 14 | 15 | impl From for Protocol { 16 | fn from(value: yhm::http::ProxyProtocol) -> Self { 17 | match value { 18 | yhm::http::ProxyProtocol::Http => Self::Http, 19 | yhm::http::ProxyProtocol::Socks5 => Self::Socks5, 20 | } 21 | } 22 | } 23 | 24 | impl From for yhm::http::ProxyProtocol { 25 | fn from(value: Protocol) -> Self { 26 | match value { 27 | Protocol::Http => yhm::http::ProxyProtocol::Http, 28 | Protocol::Socks5 => yhm::http::ProxyProtocol::Socks5, 29 | } 30 | } 31 | } 32 | 33 | /// Proxy authentication. 34 | #[derive(uniffi::Record)] 35 | pub struct Auth { 36 | pub user: String, 37 | pub password: String, 38 | } 39 | 40 | impl From for Auth { 41 | fn from(value: yhm::http::ProxyAuth) -> Self { 42 | Self { 43 | user: value.username, 44 | password: value.password.expose_secret().to_owned(), 45 | } 46 | } 47 | } 48 | 49 | impl From for yhm::http::ProxyAuth { 50 | fn from(value: Auth) -> Self { 51 | yhm::http::ProxyAuth { 52 | username: value.user, 53 | password: SecretString::new(value.password.into()), 54 | } 55 | } 56 | } 57 | 58 | /// Proxy configuration. 59 | #[derive(uniffi::Record)] 60 | pub struct Proxy { 61 | /// Protocol 62 | pub protocol: Protocol, 63 | /// Host 64 | pub host: String, 65 | /// Port 66 | pub port: u16, 67 | pub auth: Option, 68 | } 69 | 70 | impl From for Proxy { 71 | fn from(value: yhm::http::Proxy) -> Self { 72 | Self { 73 | protocol: value.protocol.into(), 74 | host: value.host, 75 | auth: value.auth.map(Into::into), 76 | port: value.port, 77 | } 78 | } 79 | } 80 | 81 | impl From for yhm::http::Proxy { 82 | fn from(value: Proxy) -> Self { 83 | yhm::http::Proxy { 84 | protocol: value.protocol.into(), 85 | host: value.host, 86 | auth: value.auth.map(Into::into), 87 | port: value.port, 88 | } 89 | } 90 | } 91 | 92 | /// Test a proxy by constructing a client and trying to ping a server. 93 | /// 94 | /// # Errors 95 | /// 96 | /// Returns error if the proxy configuration is invalid or the test failed. 97 | #[uniffi::export] 98 | pub fn test_proxy(proxy: Proxy) -> Result<(), YhmError> { 99 | let client = yhm::http::Client::proton_client() 100 | .with_proxy(proxy.into()) 101 | .build() 102 | .map_err(|e| { 103 | error!("Failed to build client with proxy: {e}"); 104 | YhmError::ProxyTest(e.to_string()) 105 | })?; 106 | client.execute(&Ping {}).map_err(|e| { 107 | error!("Failed ping server using proxy: {e}"); 108 | YhmError::ProxyTest(e.to_string()) 109 | })?; 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/watcher.rs: -------------------------------------------------------------------------------- 1 | use sqlite_watcher::watcher::DropRemoveTableObserverHandle; 2 | 3 | #[derive(uniffi::Object)] 4 | pub struct WatchHandle { 5 | _h: DropRemoveTableObserverHandle, 6 | } 7 | 8 | impl From for WatchHandle { 9 | fn from(value: DropRemoveTableObserverHandle) -> Self { 10 | Self { _h: value } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /you-have-mail-mobile/src/yhm.rs: -------------------------------------------------------------------------------- 1 | use crate::account::{Account, AccountWatcher, FFIAccountTableObserver}; 2 | use crate::android::{AccountNotificationIds, StateExtension}; 3 | use crate::backend::Backend; 4 | use crate::events::{Action, Event}; 5 | use crate::proxy::Proxy; 6 | use crate::watcher::WatchHandle; 7 | use sqlite_watcher::watcher::Watcher; 8 | use std::path::PathBuf; 9 | use std::sync::{Arc, OnceLock}; 10 | use std::time::Duration; 11 | use tracing::error; 12 | use you_have_mail_common as yhm; 13 | use you_have_mail_common::secrecy::ExposeSecret; 14 | 15 | static WATCHER: OnceLock> = OnceLock::new(); 16 | 17 | pub(crate) fn watcher() -> &'static Arc { 18 | WATCHER.get_or_init(|| Watcher::new().unwrap()) 19 | } 20 | 21 | #[derive(Debug, uniffi::Error, thiserror::Error)] 22 | pub enum YhmError { 23 | #[error("Account '{0}' already exists")] 24 | AccountAlreadyExist(String), 25 | #[error("Account '{0}' does not exist")] 26 | AccountNotFound(String), 27 | #[error("Backend: {0}")] 28 | Backend(String), 29 | #[error("State: {0}")] 30 | State(String), 31 | #[error("Backend '{0}' does not exist")] 32 | BackendNotFound(String), 33 | #[error("V1 Import: {0}")] 34 | V1Import(String), 35 | #[error("Proxy Test: {0}")] 36 | ProxyTest(String), 37 | } 38 | 39 | impl From for YhmError { 40 | fn from(value: yhm::yhm::Error) -> Self { 41 | match value { 42 | yhm::yhm::Error::Backend(e) => Self::Backend(e.to_string()), 43 | yhm::yhm::Error::State(e) => Self::State(e.to_string()), 44 | yhm::yhm::Error::AccountNotFound(e) => Self::AccountNotFound(e), 45 | yhm::yhm::Error::AccountAlreadyExist(e) => Self::AccountAlreadyExist(e), 46 | yhm::yhm::Error::BackendNotFound(e) => Self::BackendNotFound(e), 47 | } 48 | } 49 | } 50 | 51 | /// You have mail instance. 52 | #[derive(uniffi::Object)] 53 | pub struct Yhm { 54 | yhm: yhm::yhm::Yhm, 55 | } 56 | 57 | #[uniffi::export] 58 | impl Yhm { 59 | /// Create a new instance wih the given path and encryption key. 60 | /// 61 | /// # Errors 62 | /// 63 | /// Returns error if the instance failed to initialize. 64 | #[uniffi::constructor] 65 | pub fn new(db_path: String, encryption_key: String) -> Result { 66 | let key = yhm::encryption::Key::with_base64(encryption_key) 67 | .map_err(|e| yhm::yhm::Error::from(yhm::state::Error::from(e)))?; 68 | let state = yhm::state::State::new(PathBuf::from(db_path), key, Arc::clone(watcher())) 69 | .map_err(yhm::yhm::Error::from)?; 70 | 71 | state.android_init_tables().map_err(|e| { 72 | error!("Failed to init adroid tables: {e}"); 73 | YhmError::State(e.to_string()) 74 | })?; 75 | 76 | Ok(Self { 77 | yhm: yhm::yhm::Yhm::new(state), 78 | }) 79 | } 80 | 81 | /// Creates a new instance without initializing the database. 82 | /// 83 | /// # Errors 84 | /// 85 | /// Returns error if the instance failed to initialize. 86 | #[uniffi::constructor] 87 | pub fn without_db_init(db_path: String, encryption_key: String) -> Result { 88 | let key = yhm::encryption::Key::with_base64(encryption_key) 89 | .map_err(|e| yhm::yhm::Error::from(yhm::state::Error::from(e)))?; 90 | let state = 91 | yhm::state::State::without_init(PathBuf::from(db_path), key, Arc::clone(watcher())); 92 | Ok(Self { 93 | yhm: yhm::yhm::Yhm::new(state), 94 | }) 95 | } 96 | 97 | /// Get all active backends. 98 | #[must_use] 99 | pub fn backends(&self) -> Vec> { 100 | self.yhm 101 | .backends() 102 | .iter() 103 | .map(|v| Arc::new(Backend(Arc::clone(v)))) 104 | .collect() 105 | } 106 | 107 | /// Get a backend by `name`. 108 | #[must_use] 109 | pub fn backend_with_name(&self, name: &str) -> Option> { 110 | self.yhm 111 | .backend_with_name(name) 112 | .map(|v| Arc::new(Backend(Arc::clone(v)))) 113 | } 114 | 115 | /// Logout account with `email`. 116 | /// 117 | /// # Errors 118 | /// 119 | /// Returns error if the operation failed. 120 | pub fn logout(&self, email: &str) -> Result<(), YhmError> { 121 | Ok(self.yhm.logout(email)?) 122 | } 123 | 124 | /// Delete account with `email`. 125 | /// 126 | /// # Errors 127 | /// 128 | /// Returns error if the operation failed. 129 | pub fn delete(&self, email: &str) -> Result<(), YhmError> { 130 | Ok(self.yhm.delete(email)?) 131 | } 132 | 133 | /// Get the current poll interval. 134 | /// 135 | /// # Errors 136 | /// 137 | /// Returns error if the operation failed. 138 | pub fn poll_interval(&self) -> Result { 139 | let interval = self.yhm.poll_interval()?; 140 | Ok(interval.as_secs()) 141 | } 142 | 143 | /// Set the current poll `interval` in seconds 144 | /// 145 | /// # Errors 146 | /// 147 | /// Returns error if the operation failed. 148 | pub fn set_poll_interval(&self, interval: u64) -> Result<(), YhmError> { 149 | Ok(self.yhm.set_poll_interval(Duration::from_secs(interval))?) 150 | } 151 | 152 | /// Port configuration from v1. 153 | /// 154 | /// # Errors 155 | /// 156 | /// Returns error if the operation failed. 157 | pub fn import_v1(&self, path: String) -> Result<(), YhmError> { 158 | let path = PathBuf::from(path); 159 | self.yhm 160 | .import_v1(&path) 161 | .map_err(|e| YhmError::V1Import(e.to_string())) 162 | } 163 | 164 | /// Update `proxy` for account with `email` 165 | /// 166 | /// # Errors 167 | /// 168 | /// Returns error if the operation failed. 169 | pub fn update_proxy(&self, email: &str, proxy: Option) -> Result<(), YhmError> { 170 | Ok(self 171 | .yhm 172 | .update_proxy(email, proxy.map(Into::into).as_ref())?) 173 | } 174 | 175 | /// Poll the accounts and return a list of events. 176 | /// 177 | /// # Errors 178 | /// 179 | /// Returns error if the operation failed. 180 | pub fn poll(&self) -> Result<(), YhmError> { 181 | self.yhm.poll()?; 182 | Ok(()) 183 | } 184 | 185 | /// Get all accounts. 186 | /// 187 | /// # Errors 188 | /// 189 | /// Returns error if the operation failed. 190 | pub fn accounts(&self) -> Result>, YhmError> { 191 | let accounts = self.yhm.accounts()?; 192 | 193 | Ok(accounts 194 | .into_iter() 195 | .map(|v| Arc::new(Account::new(v))) 196 | .collect()) 197 | } 198 | 199 | /// Get an account with `email` 200 | /// 201 | /// # Errors 202 | /// 203 | /// Returns error if the operation failed. 204 | pub fn account(&self, email: &str) -> Result>, YhmError> { 205 | let account = self.yhm.account(email)?; 206 | 207 | Ok(account.map(|v| Arc::new(Account::new(v)))) 208 | } 209 | 210 | /// Get the last events. 211 | /// 212 | /// # Errors 213 | /// 214 | /// Returns error if the operation failed. 215 | pub fn last_events(&self) -> Result, YhmError> { 216 | Ok(self 217 | .yhm 218 | .last_events()? 219 | .into_iter() 220 | .map(Event::from) 221 | .collect()) 222 | } 223 | 224 | /// Watch available accounts and receive an updated list when any changes 225 | /// are made. 226 | /// 227 | /// # Errors 228 | /// 229 | /// Returns error if the registration failed. 230 | pub fn watch_accounts( 231 | &self, 232 | observer: Arc, 233 | ) -> Result { 234 | Ok(self 235 | .yhm 236 | .watch_accounts(FFIAccountTableObserver(observer))? 237 | .into()) 238 | } 239 | 240 | /// Apply the `action` to the account with `email`. 241 | /// 242 | /// # Errors 243 | /// 244 | /// Returns error if the action failed. 245 | pub fn apply_action(&self, email: &str, action: Action) -> Result<(), YhmError> { 246 | let action = action.into(); 247 | Ok(self.yhm.apply_actions(email, [action])?) 248 | } 249 | } 250 | 251 | #[uniffi::export] 252 | impl Yhm { 253 | /// Get or create the stable notificaiton ids for account with `email`. 254 | /// 255 | /// # Errors 256 | /// 257 | /// Returns error on failure. 258 | pub fn android_get_or_create_notification_ids( 259 | &self, 260 | email: &str, 261 | ) -> Result { 262 | self.yhm 263 | .state() 264 | .android_get_or_create_notification_ids(email) 265 | .map_err(|e| { 266 | error!("Failed to create notification ids for {email}: {e}"); 267 | YhmError::State(e.to_string()) 268 | }) 269 | } 270 | 271 | /// Get the next email notification id for account with `email`. 272 | /// 273 | /// # Errors 274 | /// 275 | /// Returns error on failure. 276 | pub fn android_next_mail_notification_id(&self, email: &str) -> Result { 277 | self.yhm 278 | .state() 279 | .android_next_mail_notification_id(email) 280 | .map_err(|e| { 281 | error!("Failed to get next mail notification id for {email}: {e}"); 282 | YhmError::State(e.to_string()) 283 | }) 284 | } 285 | } 286 | 287 | /// Generate a new encryption key. 288 | #[uniffi::export] 289 | #[must_use] 290 | pub fn new_encryption_key() -> String { 291 | yhm::encryption::Key::new().expose_secret().to_base64() 292 | } 293 | 294 | impl Yhm { 295 | pub(crate) fn instance(&self) -> &yhm::yhm::Yhm { 296 | &self.yhm 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /you-have-mail-mobile/uniffi-bindgen/uniffi-bindgen.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | uniffi::uniffi_bindgen_main(); 3 | } 4 | -------------------------------------------------------------------------------- /you-have-mail-mobile/uniffi.toml: -------------------------------------------------------------------------------- 1 | [bindings.kotlin] 2 | package_name = "dev.lbeernaert.youhavemail" 3 | cdylib_name = "youhavemail" 4 | android_cleaner = true 5 | android = true 6 | 7 | [bindings.python] 8 | cdylib_name = "youhavemail" 9 | 10 | [bindings.ruby] 11 | cdylib_name = "youhavemail" 12 | 13 | [bindings.swift] 14 | cdylib_name = "youhavemail" --------------------------------------------------------------------------------