diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..5e5d4834df7e2de0db782b3eeb015bec976c56f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig (https://editorconfig.org/) + +root = true + +[*] +end_of_line = lf +charset = utf-8 +insert_final_newline = true + +[*.java] +indent_style = space +indent_size = 2 + +[*.c] +indent_style = space +indent_size = 4 + +[*.gradle] +indent_style = space +indent_size = 4 + +[*.sh] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..1eed57a4d0f4780cc3e9314692648e08920803c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,10 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +*.ai binary +fastlane/metadata/android/en-US/images/featureGraphic.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/en-US/images/phoneScreenshots/1.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/en-US/images/phoneScreenshots/2.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/en-US/images/phoneScreenshots/3.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/en-US/images/phoneScreenshots/4.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/en-US/images/phoneScreenshots/5.png filter=lfs diff=lfs merge=lfs -text +src/main/assets/fonts/Roboto-Light.ttf filter=lfs diff=lfs merge=lfs -text +src/main/res/drawable/background_hd.jpg filter=lfs diff=lfs merge=lfs -text +src/main/res/drawable/intro1.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..66a76f85ab0680c639592623221a9db2095dbd5a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +ko_fi: adbenitez +liberapay: adbenitez +custom: "https://arcanechat.me/#contribute" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..79360f7b4c9a3ce2ebb8ef5ccc97f040180477b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report something that isn't working. +title: '' +assignees: '' +labels: bug +--- + + + +- Android version: +- Device: +- ArcaneChat version: +- Expected behavior: +- Actual behavior: +- Steps to reproduce the problem: +- Screenshots: +- Logs: + + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..3ba13e0cec6cbbfd462e9ebf529dd2093148cd69 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000000000000000000000000000000..7c920bf422b22daf5d78048cdaa792c2bd495156 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Request a new feature. +title: '' +assignees: '' +labels: enhancement +--- + + + +### Describe your feature: + +### Why do you think it is useful: diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md new file mode 100644 index 0000000000000000000000000000000000000000..980b67512b72b828bd98d72c00b15b31d42bd200 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.md @@ -0,0 +1,6 @@ +--- +name: Other +about: Start with a new blank issue. +title: '' +assignees: '' +--- diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..b1162d8ed73944bb760d561d5a3a2fe86db77124 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,175 @@ +# GitHub Copilot Instructions for ArcaneChat Android + +## Project Overview + +ArcaneChat is a Delta Chat Android client built on top of the official Delta Chat client with several improvements. It is a messenger app that uses email infrastructure for secure communication. + +**Technology Stack:** +- **Language:** Java (Java 8 compatibility) +- **Build System:** Gradle with Android Gradle Plugin 8.11.1 +- **Min SDK:** 21 (Android 5.0) +- **Target SDK:** 35 (Android 15) +- **NDK Version:** 27.0.12077973 +- **Native Components:** Rust (deltachat-core-rust submodule) +- **UI Framework:** Android SDK, Material Design Components +- **Testing:** JUnit 4, Espresso, Mockito, PowerMock, AssertJ + +## Repository Structure + +- `src/main/` - Main application source code +- `src/androidTest/` - Instrumented tests (UI tests, benchmarks) +- `src/gplay/` - Google Play flavor-specific code +- `src/foss/` - F-Droid/FOSS flavor-specific code +- `jni/deltachat-core-rust/` - Native Rust core library (submodule) +- `scripts/` - Build and helper scripts +- `docs/` - Documentation +- `fastlane/` - App store metadata and screenshots + +## Build Instructions + +### Prerequisites + +1. **Initialize submodules:** + ```bash + git submodule update --init --recursive + ``` + +2. **Build native libraries:** + ```bash + scripts/ndk-make.sh + ``` + Note: First run may take significant time as it builds for all architectures (armeabi-v7a, arm64-v8a, x86, x86_64) + +3. **Build APK:** + ```bash + ./gradlew assembleDebug + ``` + +### Build Flavors + +- **gplay:** Google Play version with Firebase Cloud Messaging (applicationId: `com.github.arcanechat`) +- **foss:** F-Droid version without proprietary services (applicationId: `chat.delta.lite`) + +### Build Outputs + +- Debug APKs: `build/outputs/apk/gplay/debug/` and `build/outputs/apk/fat/debug/` +- Release APKs require signing configuration in `~/.gradle/gradle.properties` + +## Testing + +### Running Unit Tests + +```bash +./gradlew test +``` + +### Running Instrumented Tests + +1. **Disable animations** on your device/emulator: + - Developer Options → Set "Window animation scale", "Transition animation scale", and "Animator duration scale" to 0x + +2. **Run tests:** + ```bash + ./gradlew connectedAndroidTest + ``` + +### Online Tests + +Some tests require real email credentials. Configure in `~/.gradle/gradle.properties`: +```properties +TEST_ADDR=youraccount@yourdomain.org +TEST_MAIL_PW=yourpassword +``` + +### UI Tests and Benchmarks + +- Located in `src/androidTest/java/com/b44t/messenger/` +- Test categories: `uitests/online/`, `uitests/offline/`, `uibenchmarks/` +- Run via Android Studio: Run → Edit Configurations → Android Instrumented Test + +## Coding Conventions + +### General Guidelines + +1. **Embrace existing style:** Match the coding style of the file you're editing +2. **Minimize changes:** Don't refactor or rename in the same PR as bug fixes/features +3. **Readable over paradigmatic:** Favor readability over strict Java patterns +4. **Avoid premature optimization:** Keep things simple and on point +5. **No excessive abstraction:** Avoid unnecessary factories, one-liner functions, or abstraction layers +6. **Comments:** Only add comments if they match existing style or explain complex logic + +### Architecture + +- **UI/Model Separation:** Delta Chat Core (Rust) handles the model layer +- **High-level interface:** Core provides data in UI-ready form; avoid additional transformations in UI layer +- **Direct approach:** Prefer direct implementation over excessive class hierarchies + +### Key Principles + +- Work hard to avoid options and up-front choices +- Avoid speaking about keys/encryption in primary UI +- App must work offline and with poor network +- Users don't read much text +- Consistency matters +- Primary UI should only show highly useful features + +## Common Development Tasks + +### Adding New Features + +1. Consider the UX philosophy (minimal options, offline-first, simplicity) +2. Check if core library changes are needed before implementing in UI +3. Match existing code style in modified files +4. Add instrumented tests for UI changes when appropriate +5. Update relevant documentation + +### Modifying Core Integration + +- Core library is in `jni/deltachat-core-rust/` submodule +- Java bindings are in `src/main/java/com/b44t/messenger/Dc*.java` +- JSON-RPC bindings in `chat.delta.rpc.*` package (generated via dcrpcgen) + +### Working with Translations + +- Translations managed via Transifex (not in repository) +- English source strings: `res/values/strings.xml` +- Don't mix string changes with refactoring + +### Debugging Native Code + +Decode crash symbols: +```bash +$ANDROID_NDK_ROOT/ndk-stack --sym obj/local/armeabi-v7a --dump crash.txt > decoded.txt +``` + +## WebXDC Support + +ArcaneChat has extended WebXDC support: +- `window.webxdc.arcanechat` - Version detection +- `sendToChat()` - Extra properties: `subject`, `html`, `type` (sticker/image/audio/video/file) +- External link support in apps +- `manifest.toml` - `orientation` field for landscape mode + +## Important Files + +- `build.gradle` - Main build configuration +- `CONTRIBUTING.md` - Contribution guidelines +- `BUILDING.md` - Detailed build setup +- `RELEASE.md` - Release process +- `proguard-rules.pro` - ProGuard configuration +- `google-services.json` - Firebase configuration (gplay flavor) + +## Package Structure + +- `org.thoughtcrime.securesms.*` - Main UI components (legacy namespace from Signal) +- `com.b44t.messenger.*` - Delta Chat core integration +- `chat.delta.rpc.*` - JSON-RPC bindings (generated) + +## Notes for AI Assistants + +- This is a fork of Delta Chat Android with ArcaneChat-specific improvements +- Maintain compatibility with Delta Chat core library +- Test on both gplay and foss flavors when making changes +- Native library must be rebuilt after core changes +- ProGuard is enabled in both debug and release builds +- Multi-dex is enabled due to app size diff --git a/.github/mergeable.yml b/.github/mergeable.yml new file mode 100644 index 0000000000000000000000000000000000000000..a63d9f6d5e231c99a3dffb8eada21cecab4bb959 --- /dev/null +++ b/.github/mergeable.yml @@ -0,0 +1,22 @@ +version: 2 +mergeable: + - when: pull_request.* + name: "Changelog check" + validate: + - do: or + validate: + - do: description + must_include: + regex: '#skip-changelog' + - do: and + validate: + - do: dependent + changed: + file: '**/*.java' + required: ['CHANGELOG.md'] + fail: + - do: checks + status: 'action_required' + payload: + title: CHANGELOG.md might need an update + summary: "Please update CHANGELOG.md or add #skip-changelog to the description" diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml new file mode 100644 index 0000000000000000000000000000000000000000..4267113bae03b6af0c6cbe6c9274909f4df0f724 --- /dev/null +++ b/.github/workflows/artifacts.yml @@ -0,0 +1,20 @@ +name: add artifact links to pull request +on: + workflow_run: + workflows: ["Upload Preview APK"] + types: [completed] + +jobs: + artifacts-url-comments: + name: add artifact links to pull request + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: add artifact links to pull request + uses: tonyhallett/artifacts-url-comments@v1.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + prefix: "**To test the changes in this pull request, install this apk:**" + format: "[📦 {name}]({url})" + addTo: pull diff --git a/.github/workflows/preview-apk.yml b/.github/workflows/preview-apk.yml new file mode 100644 index 0000000000000000000000000000000000000000..b8abccc7e72afb4994d62244a56d5f018fecd2d0 --- /dev/null +++ b/.github/workflows/preview-apk.yml @@ -0,0 +1,57 @@ +name: Upload Preview APK + +on: pull_request + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + name: Upload Preview APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + - name: Validate Fastlane Metadata + uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2 + - uses: Swatinem/rust-cache@v2 + with: + working-directory: jni/deltachat-core-rust + - uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: 'temurin' + - uses: android-actions/setup-android@v3 + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - uses: nttld/setup-ndk@v1 + id: setup-ndk + with: + ndk-version: r27 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Compile core + env: + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + run: | + export PATH="${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/" + scripts/install-toolchains.sh && scripts/ndk-make.sh armeabi-v7a + + - name: Build APK + run: ./gradlew --no-daemon -PABI_FILTER=armeabi-v7a assembleFossDebug + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: app-preview.apk + path: 'build/outputs/apk/foss/debug/*.apk' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ea8c8f9655749e66e5ad6b90da99f91b1687b6f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Upload Release APK + +on: + push: + tags: + - 'v*.*.*' + +jobs: + check: + runs-on: ubuntu-latest + outputs: + match: ${{ steps.check-tag.outputs.match }} + steps: + - id: check-tag + run: | + if [[ "${{ github.event.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then + echo ::set-output name=match::true + fi + + build: + needs: check + if: needs.check.outputs.match == 'true' + name: Upload Release APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - uses: Swatinem/rust-cache@v2 + with: + working-directory: jni/deltachat-core-rust + - uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'temurin' + - uses: android-actions/setup-android@v3 + - uses: nttld/setup-ndk@v1 + id: setup-ndk + with: + ndk-version: r27 + + - name: Compile core + env: + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + run: | + export PATH="${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/:${ANDROID_NDK_ROOT}" + ./scripts/install-toolchains.sh && ./scripts/ndk-make.sh + + - name: Build APK + run: | + mkdir -p ~/.gradle + echo -n ${{ secrets.KEYSTORE_FILE }} | base64 -d >> ~/keystore.jks + echo "DC_RELEASE_STORE_FILE=$HOME/keystore.jks" >> ~/.gradle/gradle.properties + echo "DC_RELEASE_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> ~/.gradle/gradle.properties + echo "DC_RELEASE_KEY_ALIAS_FDROID=${{ secrets.ALIAS_FDROID }}" >> ~/.gradle/gradle.properties + echo "DC_RELEASE_KEY_ALIAS_GPLAY=${{ secrets.ALIAS_GPLAY }}" >> ~/.gradle/gradle.properties + echo "DC_RELEASE_KEY_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> ~/.gradle/gradle.properties + ./gradlew assembleFossRelease + rm build/outputs/apk/foss/release/*universal* + ./gradlew assembleGplayRelease + mv build/outputs/apk/gplay/release/*universal* build/outputs/apk/foss/release/ArcaneChat-gplay.apk + + - name: Release on GitHub + uses: softprops/action-gh-release@v1 + with: + token: "${{ secrets.GITHUB_TOKEN }}" + body: '[Get it on Google Play](https://play.google.com/store/apps/details?id=com.github.arcanechat) [Get it on F-Droid](https://f-droid.org/packages/chat.delta.lite) [Get it on GitHub](https://github.com/ArcaneChat/android/releases/latest/download/ArcaneChat-gplay.apk)' + prerelease: ${{ contains(github.event.ref, '-beta') }} + fail_on_unmatched_files: true + files: build/outputs/apk/foss/release/*.apk + + - name: Release on ZapStore + run: | + export CHECKSUM=6e2c7cf6da53c3f1a78b523a6aacd6316dce3d74ace6f859c2676729ee439990 + curl -sL https://cdn.zapstore.dev/$CHECKSUM -o zapstore + if echo "$CHECKSUM zapstore" | sha256sum -c --status; then + chmod +x zapstore + SIGN_WITH=${{ secrets.NOSTR_KEY }} ./zapstore publish --indexer-mode + else + echo "ERROR: checksum doesn't match!" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e6a6ce3d96c2803722cb77f76c4d149c63bca676 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +*.keystore +.classpath +project.properties +.project +.settings +bin/ +gen/ +/gplay/ +.idea/ +*.iml +*.so +out +tests +lint.xml +local.properties +ant.properties +.DS_Store +build.log +build-log.xml +.gradle +build +signing.properties +library/lib/ +library/obj/ +ffpr +test/androidTestEspresso/res/values/arrays.xml +obj/ +jni/libspeex/.deps/ +ndkArch + +# ignore debug symbols created by ./tools/upload-release.sh +*-symbols.zip + +# ignore private scripts and directories, eg. local2github.prv.sh +*.prv* + +# contains files for ndk-build when done from gradle. +.externalNativeBuild + +# no vi tmp files +*.swp + +jni/x86 +jni/x86_64 +jni/armeabi +jni/armeabi-v7a +jni/arm64-v8a + +artwork/drawable*/ +artwork/mipmap-*/ +*~ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..58d5a122a14cc7cf77532944108807b152384949 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "jni/deltachat-core-rust"] + path = jni/deltachat-core-rust + url = https://github.com/ArcaneChat/core diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000000000000000000000000000000000000..53f8bbb00c01343809c3fb3c5924d30f7eefbdc3 --- /dev/null +++ b/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com +lang_map = id: in, ja_JP: ja, nl_NL: nl, pt_BR: pt-rBR, zh_CN: zh-rCN, zh_TW: zh-rTW + +[o:delta-chat:p:delta-chat-app:r:stringsxml] +file_filter = src/main/res/values-/strings.xml +source_file = src/main/res/values/strings.xml +source_lang = en +type = ANDROID + diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000000000000000000000000000000000000..98af21e496f3a481dafc187b308a4150280f6f90 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,245 @@ +# Building and Testing + +This document describes how to set up the build environment, +build and test the app. Before diving into developing, please +first read [CONTRIBUTING.md](./CONTRIBUTING.md) for general +contribution hints and conventions. + +Please follow all steps precisely. +If you run into troubles, +ask on one of the [communication channels](https://delta.chat/contribute) for help + + +## Check Out Repository + +When checking out _deltachat-android_, make sure also to check out the +subproject _deltachat-core-rust_: + +- When using Git, you can do this initially by + `$ git clone --recursive https://github.com/deltachat/deltachat-android` + or later by `git submodule update --init --recursive`. If you do this in your + home directory, this results in the folder `~/deltachat-android` which is just fine. + +## Generate JSON-RPC bindings + +To generate/update the JSON-RPC bindings (ex. `chat.delta.rpc.*` package) +install Rust tooling (read sections below) and the [dcrpcgen tool](https://github.com/chatmail/dcrpcgen) +then generate the code running the script: + +``` +./scripts/generate-rpc-bindings.sh +``` + +## Build Using Nix + +The repository contains [Nix](https://nixos.org/) development environment +described in `flake.nix` file. +If you don't have Nix installed, +the easiest way is to follow the [Lix installation instructions](https://lix.systems/install/) +as this results in a setup with [Flakes](https://nixos.wiki/wiki/Flakes) feature enabled out of the box +and can be cleanly uninstalled with `/nix/nix-installer uninstall` once you don't need it anymore. + +Once you have Nix with Flakes feature set up start the development environment shell: +``` +nix develop +``` +Nix development environment contains Rust with cross-compilation toolchains and Android SDK. + +To [build an APK](https://developer.android.com/studio/build/building-cmdline) run the following 2 steps. +Note that the first step may take some time to build for all architectures. You can optionally read +[the first comment block in the `ndk-make.sh` script](https://github.com/deltachat/deltachat-android/blob/master/scripts/ndk-make.sh) +for pointers on how to build for a specific architecture. +``` +$ scripts/ndk-make.sh +$ ./gradlew assembleDebug +``` + +Resulting APK files can be found in +`build/outputs/apk/gplay/debug/` and +`build/outputs/apk/fat/debug/`. + +## Build Using Dockerfile + +Another way to build APK is to use provided `Dockerfile` +with [Docker](https://www.docker.com/) or [Podman](https://podman.io/). +Podman is a drop-in replacement for Docker that does not require root privileges. + +If you don't have Docker or Podman setup yet, read [how to setup Podman](#setup-podman) +below. If you don't want to use Docker or Podman, read [how to manually install the +build environment](#install-build-environment). + +First, build the image `deltachat-android` by running +``` +podman build --build-arg UID=$(id -u) --build-arg GID=$(id -g) . -t deltachat-android +``` +or +``` +docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) . -t deltachat-android +``` + +Then, run the image: +``` +podman run --userns=keep-id -it --name deltachat -v $(pwd):/home/app:z -w /home/app localhost/deltachat-android +``` +or +``` +docker run -it --name deltachat -v $(pwd):/home/app:z -w /home/app localhost/deltachat-android +``` + +You can leave the container with Ctrl+D or by typing `exit` and re-enter it with +`docker start -ia deltachat` or `podman start -ia deltachat`. + +Within the container, install toolchains and build the native library: +``` +deltachat@6012dcb974fe:/home/app$ scripts/install-toolchains.sh +deltachat@6012dcb974fe:/home/app$ scripts/ndk-make.sh +``` + +Then, [build an APK](https://developer.android.com/studio/build/building-cmdline): +``` +deltachat@6012dcb974fe:/home/app$ ./gradlew assembleDebug +``` + +### Troubleshooting + +- Executing `./gradlew assembleDebug` inside the container fails with `The SDK directory '/home/user/Android/Sdk' does not exist.`: + + The problem is that Android Studio (outside the container) automatically creates a file `local.properties` with a content like `sdk.dir=/home/username/Android/Sdk`, + so, Gradle-inside-the-container looks for the Sdk at `/home/username/Android/Sdk`, where it can't find it. + You could: + - either: remove the file or just the line starting with `sdk.dir` + - or: run `./gradlew assembleDebug` from outside the container (however, there may be incompatibility issues if different versions are installed inside and outside the container) + +- Running the image fails with `ERRO[0000] The storage 'driver' option must be set in /etc/containers/storage.conf, guarantee proper operation.`: + + In /etc/containers/storage.conf, replace the line: `driver = ""` with: `driver = "overlay"`. + You can also set the `driver` option to something else, you just need to set it to _something_. + [Read about possible options here](https://github.com/containers/storage/blob/master/docs/containers-storage.conf.5.md#storage-table). + +## Setup Podman + +These instructions were only tested on a Manjaro machine so far. If anything doesn't work, please open an issue. + +First, [Install Podman](https://podman.io/getting-started/installation). + +Then, if you want to run Podman without root, run: +``` +sudo touch /etc/subgid +sudo touch /etc/subuid +sudo usermod --add-subuids 165536-231072 --add-subgids 165536-231072 yourusername +``` +(replace `yourusername` with your username). +See https://wiki.archlinux.org/index.php/Podman#Rootless_Podman for more information. + +## Install Build Environment (without Docker or Podman) + +To setup build environment manually: +- _Either_, in Android Studio, go to "Tools / SDK Manager / SDK Tools", enable "Show Package Details", + select "CMake" and the desired NDK (install the same NDK version as the [Dockerfile](https://github.com/deltachat/deltachat-android/blob/master/Dockerfile)), hit "Apply". +- _Or_ read [Dockerfile](https://github.com/deltachat/deltachat-android/blob/master/Dockerfile) and mimic what it does. + +Then, in both cases, install Rust using [rustup](https://rustup.rs/) +and Rust toolchains for cross-compilation by executing `scripts/install-toolchains.sh`. + +Then, configure `ANDROID_NDK_ROOT` environment variable to point to the Android NDK +installation directory e.g. by adding this to your `.bashrc`: + +```bash +export ANDROID_NDK_ROOT=${HOME}/Android/Sdk/ndk/[version] # (or wherever your NDK is) Note that there is no `/` at the end! +export PATH=${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/:${ANDROID_NDK_ROOT} +``` + +After that, call `scripts/ndk-make.sh` in the root directory to build core-rust. +Afterwards run the project in Android Studio. The project requires API 25. + +With chance, that's it :) - if not, read on how to set up a proper development +environment. + + +## Install Development Environment + +1. Some libs required by Android Studio may be missing on 64 bit Linux machines + [Source](https://developer.android.com/studio/install.html)], so for Ubuntu execute + `$ sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386` + and for Fedora execute + `$ sudo yum install zlib.i686 ncurses-libs.i686 bzip2-libs.i686`. + +2. Download Android Studio from (android-studio-ide-...-linux.zip) + and unpack the archive which contains a single folder called `android-studio`; + move this folder e.g. to `~/android-studio`. + +3. To launch Android Studio for the first time, open a terminal, navigate to + `~/android-studio/bin`, execute `./studio.sh` and use all the standard values + from the wizard. + +4. Android Studio now asks you if you want to open an existing project; + choose `~/deltachat-android` as created in the "Build" chapter (Android Studio starts to + build the project, however, there are some steps missing before this will + succeed). + +5. If components are missing, click on the corresponding error + message and install eg. required SDKs and the "Build-Tools" (you should + also find the option at "Tools / Android / SDK Manager / SDK Platforms"). + Now the build should succeed - but the app still misses the native part. + +6. Download Android NDK from + [NDK Archives](https://developer.android.com/ndk/downloads) + and extract the archive containing a single folder + called something like `android-ndk-r23b-linux`; move this folder e.g. to `~/android-ndk`. + +7. Export the folder path to your environment as `ANDROID_NDK_ROOT` and add it to `PATH`. + You can achieve this e.g. by adding this to your `.bashrc` + ```bash + export ANDROID_NDK_ROOT=${HOME}/android-ndk + export PATH=${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/:${ANDROID_NDK_ROOT} + ``` + +## Run UI Tests and Benchmarks + +- You don't necessarily need a dedicated testing device. + Backup your current account first, maybe there are some bugs in switching accounts. + +- You can run benchmarks on either an emulated device or a real device. + You need at least Android 9. For better benchmark results, + you should run the benchmark on a real device and make sure that the core is compiled in release mode. + +- Disable animations on your device, otherwise the test may fail: + at "Developer options" + set all of "Window animation scale", "Transition animation scale" and "Animator duration scale" to 0x + +- In Android Studio: "File" / "Sync project with gradle files" + +- In Android Studio: "Run" / "Edit configurations" / "+" / "Android Instrumented test": + Either select a specific class or select "All in Module" / "OK" / + Select your configuration in the toolbar / Click on the green "run" button in the toolbar to run the tests + +### Get the benchmark results + +When the benchmark is done, you will get a result like +`MEASURED RESULTS (Benchmark) - Going thorough all 10 chats: 11635,11207,11363,11352,11279,11183,11137,11145,11032,11057`. +You can paste `11635,11207,11363,11352,11279,11183,11137,11145,11032,11057` +into a cell in a LibreOffice spreadsheet, do "Data" / "Text to columns", +choose `,` as a separator, hit "OK", and create a diagram. + +### Run online tests + +For some tests, you need to provide the credentials to an actual email account. +You have 2 ways to do this: + +1. (Recommended): Put them into the file ~/.gradle/gradle.properties (create it if it doesn't exist): + ``` + TEST_ADDR=youraccount@yourdomain.org + TEST_MAIL_PW=youpassword + ``` + +2. Or set them via environment variables. + +## Decoding Symbols in Crash Reports + +``` +$ANDROID_NDK_ROOT/ndk-stack --sym obj/local/armeabi-v7a --dump crash.txt > decoded.txt +``` + +`obj/local/armeabi-v7a` is the extracted path from `deltachat-gplay-release-X.X.X.apk-symbols.zip` file from https://download.delta.chat/android/symbols/ + +Replace `armeabi-v7a` by the correct architecture the logs come from (can be guessed by trial and error) diff --git a/CHANGELOG-upstream.md b/CHANGELOG-upstream.md new file mode 100644 index 0000000000000000000000000000000000000000..24bda9601331ea8395e30e4a98cb82bcc563c182 --- /dev/null +++ b/CHANGELOG-upstream.md @@ -0,0 +1,2945 @@ +# Delta Chat Android Changelog + +## Unreleased + +* Allow to add relay from clipboard or image if camera permission is not granted + +## v2.33.1 +2025-12 + +* Target Android 16 +* Change color of links in text messages +* Improve edge-to-edge support +* Metadata protection: protect message recipients +* Allow to withdraw channel invite links and QR codes +* Allow to open externally links clicked inside in-chat apps +* Do not show "1 member" when the process of joining the group is not finished +* Make search case-insensitive for non-ASCII chat and contact names +* Improve handling of video recoding +* Send .webm videos as file, they are not supported by all platforms +* Tweak advanced section and wording of some advanced options +* Fix: avoid crash in push notifications handling +* Fix: avoid freezing in background +* Fix: clean up web storage of deleted in-chat apps +* Fix: avoid crash when exporting some files with wrong image MIME type +* Expose new "Multi-device mode" option instead of "Delete from server" for chatmail profiles +* Opened in-chat apps got a 'About Apps' menu item +* Avoid gray avatar on profile creation +* Avoid last item in chat list being covered by the floating button +* Add disk usage statistics to log +* New experimental feature: several addresses per profile +* Update to core 2.33.0 + +## v2.25.0 +2025-11 + +* Make it possible to invite members into a channel via a QR code, + and make channels more secure +* metadata protection: protect Date header +* metadata protection: protect Autocrypt header +* better multi-device: synchronize group creation across devices +* data saving: do not send Autocrypt header in read receipts +* improve onboarding speed +* allow to save to storage files shared from inside in-chat apps +* reduce app size +* don't show badge counter in app icon for the permanent background notification +* fix sorting of old media in gallery +* fix text direction in "x members" subtitle for RTL languages +* fix group invite QR screen's layout +* tweak text hints in advanced classic e-mail configuration +* remove deprecated "companion app" code +* remove deprecated "Watch Sent Folder" preference +* remove deprecated "send self-report" preference +* don't show email address in shared vcard +* update to core 2.25.0 + +## v2.22.0 +2025-10 + +* target Android 15 +* improve readability of info messages in dark mode +* drop too short disappearing messages options +* fix Direct Share shortcuts +* fix: don't show error message when cancelling profile creation +* enable permanent notification by default if push notifications are not available +* hide "clone chat" and member list for incoming channels +* show warning if background notifications will be unreliable +* warn if the app has not been updated after 6 months instead of 1 year +* avoid "unknown sender for this chat" error +* properly display "Messages are end-to-end encrypted." in all encrypted groups +* show dialog if user has permanently denied camera permission and tries to take picture for group avatar +* several small fixes and improvements +* add experimental built-in calls +* update to core 2.22.0 + +## v2.11.0 +2025-08 + +* add "After 1 year" option to disappearing messages +* improve image quality when setting group avatars +* add Estonian translation, update other translations +* allow to clone email chats +* fix some small bugs +* update to core 2.11.0 + +## v2.10.0 +2025-08 + +* fix "Archived" item's layout in chat-list +* don't enlarge "Saved Messages" and "Devices Messages" avatars on click +* share email address for email contacts instead of vCard +* open existing encrypted chat when opening a mailto link or clicking an email address in a message bubble +* update to core 2.10.0 + +## v2.9.0 +2025-07 + +* hide contact email addresses in search results +* disable non-functional message editing and ephemeral messages timer settings in classic email thread chat +* don't enlarge email chats avatar placeholder +* improve message date/status footer layout, also in RTL languages +* display correct text when receiving a "Disappearing messages enabled" system message +* Update to core 2.9.0 + +## v2.8.0 +2025-07 + +* Profiles focus on recognizing contacts +* See the number of media directly in the profile, no need to tap around +* Clearer app lists by removing redundant "App" subtitle +* New button for quick access to the apps sent in current chat +* New icon for the in-chat apps button +* Improve hint for app drafts +* Add Text-To-Speech (TTS) support for in-chat apps +* New icon for the QR icon +* Start rebuilding the experimental broadcast lists + into proper channels - note that this is work-in-progress +* Improved separation between unencrypted chats/contacts and encrypted ones, avoiding mixing of encrypted and unencrypted messages in the same chat +* Removed padlocks, as encrypted is the default "normal" state. Instead, unencrypted email is marked with a small email / letter (✉️) icon +* Classic email chats/threads get a big email / letter icon making it easy to recognize +* After some time, add a device message asking to donate. Can't wait? Donate today at https://delta.chat/donate +* Allow to sort profiles up in the profile switcher +* Add new option to create unencrypted email thread +* Green checkmarks are removed where they mostly refer to guaranteed encryption, which is the default now. They are still used for profile's "Introduced by" +* Update to core 2.8.0 + +## v1.58.4 +2025-05 + +* make in-chat apps properly work when they are not sent yet, in draft mode +* better avatar quality +* some more bug fixes and updated translations +* update to core 1.159.5 + +## v1.58.3 +2025-05 + +* fix: webxdc.selfName uses the name otherwise displayed +* fix potential crash on startup +* add donation link to app settings +* update to core 1.159.3 + +## v1.58.2 +2025-04 + +* fix draft writing area disappearing for some chats +* update to core 1.159.2 + +## v1.58.1 +2025-04 + +* tapping info messages with contacts open the contact's profile +* hide superfluous "Show Classic E-mails" advanced setting for chatmail +* show profile bio/status under name in main settings screen +* remove mostly non-telling transport addresses when referring to a contact; + the contact's profile gives a much better overview +* Disable AEAP to enable us to overhaul some things - there are big changes underway in this area, which will come in a few months +* don't display email address in contact list and member list for contacts with green-checkmark +* avoid crash in Notifications preferences if ringtone title can't be read +* don't display forwarded messages as "edited" if original message was edited +* support importing contact from ProtonMail vCard attachments received in chats +* send encrypted in the experimental broadcast lists feature +* wait for QR scan (or invite link click) process to complete before allowing to send messages +* show connectivity status dot when profile is connecting or not connected +* never send Autocrypt-Gossip in broadcast lists. +* update to core 1.159.1 + +## v1.56.1 +2025-03 + +* ignore click in info-messages from deleted in-chat apps +* data saving: do not send messages to the server if user is the only member of the chat in single-device usage +* protect metadata: encrypt message's sent date +* do not fail to send messages in groups if some encryption keys are missing +* synchronize contact name changes across devices +* fix changing group names that was not working in some situations +* fix: do not show outdated message text in "Message Info" of an edited message +* some more small bug fixes and updated translations +* update to core 1.158.0 + +## v1.56.0 +2025-03 + +* allow to edit messages +* allow to delete messages for everyone +* add mute option "8 hours" +* add menu option to easily save/unsave selected message +* improve deletion confirmation for "Device Messages" +* remove dangerous encryption options +* always paste as plain text in message draft area +* some small bug fixes and updated translations +* update to core 1.157.2 + +## v1.54.4 +2025-03 + +* allow better avatar (profile picture) quality +* remove notifications from chat that was deleted from other device +* when a chat is deleted, also delete its messages from server +* avoid freezing when opening the app for the first time after install +* avoid crash when adding chat shortcut to home screen +* some small bug fixes and updated translations +* update to core 1.156.3 + +## v1.54.3 +2025-03 + +* allow to add any chat to the home screen +* update "forward message" icon and organize the messages actions bar +* do not allow non-members to change ephemeral timer settings of groups +* properly display padlock when the message is not sent over the network +* sync message deletion to other devices +* sync chat deletion across devices +* Show sender in "Saved Messages" +* allow scanning multiple QR-invitation codes without needing to wait for completion to scan the next one +* when reactions are seen in one device, remove notification from your other devices +* don't disturb with notification when someone leave a group +* detect incompatible profiles from newer app version when importing them +* prepare the app for receiving edited messages +* prepare the app for receiving message deletion requests +* do some small bug fixes +* update translations +* update to core 1.156.2 + +## v1.54.0 +2025-02 + +* enhanced "Saved Messages" feature, now when forwarding a message to "Saved Messages" chat, it retains the sender information and a button to jump to the original message +* Saved messages are marked by a bookmark sign +* improve explanation when blocking a contact +* improve wording in empty "apps" and "files" tabs in chat media screen +* remove deprecated/legacy built-in "half-camera" +* UI improvement: keep avatars aligned to message bubble when message has reactions +* fix problems when opening attachments in external apps +* fix a bug with some big images appearing as blank/transparent +* some other small bug fixes +* update translations +* update to core 1.155.4 + +## v1.52.1 +2025-01 + +* the app now requires less storage on your SD card by deduplicating newly received/sent files +* some small bug fixes +* update translations +* update to core 1.155.1 + +## v1.52.0 +2025-01 + +* new group consistency algorithm +* fix: don't show animated .webp stickers as static stickers +* fix the chat shortcuts (created via long-press in launcher) to properly support multi-profile +* fix some small bugs in certain android versions and special situations +* avoid the app freezing in slow phones in some situations +* improve menu in the help screen +* update translations +* update to core 1.155.0 + +## v1.50.5 +2025-01 + +* fix push-notifications handling for certain devices where it was not working correctly +* update translations +* using core 1.153.0 + +## v1.50.4 +2025-01 + +* properly send as animated stickers GIF files selected from keyboard +* improve emoji picker in landscape mode and when changing from landscape to portrait +* avoid crash when receiving push notifications if the user restricted the app from working in background +* improve UI when attaching a file or image to easily recognize it is attached but not sent yet +* avoid slow loading of in-chat apps in some devices when quickly re-opening an app after closing it +* allow to select multiple images at once in the media picker via "Gallery" button +* mark holiday notice messages as bot-generated +* don't mark contacts as bot when receiving location-only and sync messages +* prefer to encrypt even if peers have their preference to "no preference" +* start ephemeral messages timers when the chat is archived or noticed +* several bug fixes and updated translations +* update to core 1.153.0 + +## v1.50.3 +2024-12 + +* Add in-chat apps picker to attachments options +* Notify replies and reactions to your messages in muted chats (can be disabled in settings) +* Cache HTTP GET requests (ex. when loading images from HTML messages) +* update to core 1.152.0 + +## v1.50.2 +2024-12 + +* Encrypt notification tokens +* update to core 1.151.5 + +## v1.50.0 +2024-12 + +* New emoji picker with support for more emojis +* Webxdc apps can now trigger notifications +* Webxdc apps can now deep-link to internal sections when you click their info-messages in chat +* Add "Show in Chat" to the menu of opened Webxdc apps +* Reverse order of messages in the notification group +* Notify reactions to own messages +* Improve the button to start Webxdc apps +* Make account deletion confirmation dialog faster +* Rename "Back up Chats to External Storage" to "Export Backup" +* Improve compatibility with classic email clients in the outgoing messages +* Removed internal font scaling setting in favor of the better system settings +* Use privacy-preserving webxdc addresses +* Use Rustls for connections with strict TLS +* QR codes for adding contacts and joining groups provide help when opened in a normal browser +* Mark Saved Messages chat as protected +* Allow the user to replace maps integration +* fix: Trim whitespace from scanned QR codes +* fix quotes: Line-before-quote may be up to 120 character long instead of 80 +* fix: Prevent accidental wrong-password-notifications +* fix: Remove footers from "Show Full Message..." +* fix: Only add "member added/removed" messages if they actually do that +* fix: Update state of message when fully downloading it +* fix: send message: Do not fail if the message does not exist anymore +* fix: Do not percent-encode dot when passing to autoconfig server (so, fix handling of some servers) +* fix displaynames not being updated when intially scanned by a QR code +* several bug fixes +* update to core 1.151.3 + +## v1.48.3 +2024-10 + +* new Proxy settings screen available at "Advanced / Proxy" +* manage a list of HTTP(S), SOCKS5 or Shadowsocks Proxies +* Proxies icon shown on the chatlist if proxies are used +* share Proxies by showing a QR code +* scan Proxies' QR code and use them +* make Proxy URLs inside Delta Chat tappable +* open Delta Chat when tapping Proxy URLs in other apps +* support for realtime webxdc apps moved out of experimental and enabled by default +* realtime webxdc apps can be disabled at "Settings / Advanced" +* "New Contact / Link" button to view, share or copy the invite line +* "New Contact / Scan" button to easier access the scanner functionality +* open "New Contact" scan/show activities directly, do not try to be too smart and open the last active tab +* allow to attach multiple images in one step +* to easier differ between multiple profiles, set a "Private Tag" (long tap profile switcher) +* "Private Tag" is shown in notifications +* improve profile deletion dialog: show name, size and avatar of the profile being deleted +* show profile name in title bar when the user has multiple profiles +* improve profile switcher layout +* improve notification: allow to "Mark Read" from the notification +* search for unread chats in the search's three-dot-menu +* allow pasting QR codes from "Add As Second Device" screen +* save traffic by supporting "IMAP COMPRESS" +* automatic reconfiguration, e.g. switching to implicit TLS if STARTTLS port stops working +* parallelize IMAP and SMTP connection attempts +* improve DNS caching +* always use preloaded DNS results +* prioritize cached results if DNS resolver returns many results +* always move auto-generated messages to DeltaChat folder +* ignore invalid securejoin messages silently +* delete messages from a chatmail server immediately by default +* make resending pending messages possible +* don't SMTP-send messages to self-chat if BccSelf is disabled +* HTTP(S) tunneling +* don't put displayname into From/To/Sender if it equals to address +* hide sync messages from INBOX (use IMAP APPEND command to upload sync messages) +* more verbose SMTP connection establishment errors +* add "Learn More" button to "Manage keys" +* visual feedback when tapping the action button of a message +* log unexpected message state when resending fails +* smoother backup and "Add Second Device" progress bars +* assign messages to ad-hoc group with matching name and members +* use stricter TLS checks for HTTPS downloads (images in HTML mails, Autoconfig) +* improve logging for failed QR code scans, AEAP, Autocrypt, notification permissions and sending errors +* improve logging of multi account setup (log account ID) +* show more context for the "Cannot establish guaranteed..." info message +* show file name in "Message Info" +* show root SMTP connection failure in connectivity view +* fix: Sort received outgoing message down if it's fresher than all non fresh messages +* fix: avoid app being killed when processing a PUSH notification +* fix crash when refreshing avatar +* fix crash in gallery +* fix: shorten message text in locally sent messages too +* fix: Set http I/O timeout to 1 minute rather than whole request timeout +* fix: don't sync QR code token before populating the group +* fix: do not get stuck if the message to download does not exist anymore +* fix: do not attempt to reference info messages +* fix: do not get stuck if there is an error transferring backup +* fix: make it possible to cancel ongoing backup transfer +* fix: reset quota when entering a new address +* fix: better detection of file extensions +* fix: "database locked" errors +* fix: never initialize realtime channels if realtime is disabled +* fix reception of realtime channels +* fix: normalize proxy URLs +* fix connections getting stuck in "Updating..." sometimes +* fix scanning "add second device" QR code from scanner above chatlist +* fix warning about wrong password +* fix app getting stale when receiving a PUSH notifications takes longer +* fix app getting stale on network changes +* fix: skip IDLE if we got unsolicited FETCH +* update translations and local help +* update to core 1.148.6 + + +## v1.46.14 +2024-09 + +* add monochrome/themed launcher icon support +* allow to remove the selected profile in "Switch Profile" dialog +* improve display of selected profile in "Switch Profile" dialog +* improve the hit/tap area to open "Switch Profile" dialog in the main screen's toolbar +* add support for system per-app language and remove in-app language selector +* remove the experimental "encrypt database" checkbox in classic registration screen +* fix various bugs +* update to core 1.142.12 + + +## v1.46.13 +2024-08 + +* improve contact profile's "Edit Name" dialog +* upgrade the status bar to modern Android look and feel +* add direct support for android14, required to be able to continue shipping to Google Play +* increase minimal supported android to 5; as required by updating several outdated dependencies +* drop support for gmail oauth2, gmail can still be used using "App Passwords", you'll get hints as needed + (reason for dropping was unmaintainable bureaucracy and costs added by google) +* update various dependencies for added security and stability +* jcenter (a dependency origin) is closing, move dependencies to other origins +* when SOCKS5 is enabled, route autoconfig and oauth2 config there +* fix encryption compatibility with old Delta Chat clients +* fix crashes when opening log view with many lines +* fix: hide copy to clipboard while QR is not ready +* fix moving outgoing auto-generated messages to the "DeltaChat" folder +* fix: try to create "INBOX.DeltaChat" if "DeltaChat" is not possible for some provider +* fix receiving messages with "DeltaChat" folder cannot be selected +* fix: do not crash on unknown "Certificate Checks" values +* update provider database +* update to core 1.142.8 + + +## v1.46.10 +2024-08 + +* mark bots in chat titles and profiles as such +* if the experimental videochat is enabled, invitations can be sent via the "Attach" menu now +* show potentially dangerous buttons with red color +* focus on name and profile images in reaction details; the address is available on tap +* focus on name and profile image in profile switcher; the address is still shown for classic e-mail profiles +* add device message about new placement of "Switch Profile" if more than one profile is in use before update +* update translations +* using core 1.142.2 + + +## v1.46.8 +2024-08 + +* "Share Contact" directly from a contact's profile +* add "Share Invite Link" to "New Contact" screen +* add "Invite Friends" to main menu +* cleanup "Profile Switcher", long tap to delete profiles +* "Mute Notifications" via a long tap directly from "Profile Switcher" +* search non-english messages case-insensitive +* display attached contact's names in summaries and quotes +* protect From: and To: metadata where possible +* do not reveal sender's language metadata in read receipts +* allow importing contacts exported by Proton Mail +* for chatmail profile, hide error prone "add contact manually" in favor to invite links +* automatically expand "Password and Account / Advanced" if there were advanced options set before +* show potentially dangerous menu entries with red color +* remove "Switch Profile" from main menu, as this very often used option causes confusion with finger memory and other menus; + instead, just tap your profile image in the upper left corner to add or to switch profiles +* prevent creating contact without encryption in chatmail profiles via mailto:-links +* no unarchiving of groups on member removal messages +* improve caching of DNS results +* focus on name for QR code titles +* report first error instead of the last on connection failure +* long tap email address in contact's profile for copying to clipboard +* fix battery drain due to endless IMAP loop +* fix: remove push notification toggle, it is not needed as raised false expectations +* fix: keep "chatmail" state after failed reconfiguration +* fix issues with failed backup imports +* fix: avoid group creation on member removal messages +* fix downloading partially downloaded messages +* fix various networking bugs +* update translations and local help +* update to core 1.142.2 + + +## v1.46.7 +2024-07 + +* add option to mark all selected chats as being "Read" (long tap a chat to start select mode) +* new, single-device chatmail profiles default to "Delete Messages after Download" +* when using a chatmail profile on multiple devices, deletion is changed to "Automatic" + (deletion strategy is up to the server then) +* fix back-button behaviour in the welcome screen +* update translations and local help +* using core 1.140.2 + + +## v1.46.5 +2024-06 + +* support webxdc apps with experimental realtime channels ("Settings / Advanced / Realtime Webxdc Channels") +* fewer traffic in larger chatmail groups by allowing more than 50 recipients per time +* log debug level (mostly foreign modules) only if "Settings / Advanced / Developer Mode" is enabled +* fix: avoid asking to disable battery optimisations when creating the second profile +* fix hangs on low/no network during onboarding +* fix: cancel muting does not cancel selection in chatlist +* fix migrated address losing verified status and key on experimental AEAP +* fix: allow creation of groups by outgoing messages without recipients +* fix: avoid group splits by preferring ID from encrypted header over references for new groups +* fix: do not fail to send images with wrong extensions +* fix: retry sending MDNs on temporary error +* fix: do not miss new messages while expunging the folder +* fix missing logging info lines +* fix: remove group member locally even if sending fails +* fix: revert group member addition if the corresponding message couldn't be sent +* update translations and local help +* update to core 1.140.2 + + +## v1.46.3 +2024-06 + +* Disable FCM PUSH notification support for F-Droid and other non-Google-Play-builds +* using core 1.139.5 + + +## v1.46.2 +2024-06 + +* fix: create new profile when scanning/tapping QR codes outside "Add Profile" +* update translations and local help +* using core 1.139.5 + + +## v1.46.1 +2024-05 + +* new onboarding: you can create a new profile with one tap on "Create New Profile" - + or use an existing login or second-device-setup as usual +* use FCM PUSH notification if supported by providers (as chatmail) and by the operating system +* do not ask for disabling "battery optimisations" when PUSH notifications are working +* add an option to disable PUSH notifications +* contacts can be attached as "Cards" at "Attach / Contact"; + when the receiver taps the cards, guaranteed end-to-end encrypted can be established +* "Profiles" are names as such throughout the app; + note that these profiles exist on the device only, there is nothing persisted on the server +* adding contacts manually at "New Chat / New Contact / Add Contact Manually" +* send any emoji as reaction +* show reactions in summaries +* nicer summaries by using some emojis for attachment types +* pin/archive/etc chats directly from search result +* new map for - still experimental - location streaming (enable at "Settings / Advanced") +* ask for system unlock before accessing "password & account" +* advanced settings resorted, you'll also find "password & account" and "show classic emails" there +* improve resilience by adding references to the last three messages +* one-to-one chats are read-only during reasonable run of securejoin +* if securejoin is taking longer than expected, a warning is shown and messages can be sent +* improve resilience by including more entries in DNS fallback cache +* improve anonymous mailing lists by not adding hostname to Message-ID +* harden share-to-delta +* add second device's troubleshooting is always available offline now +* hide folder options if not supported by the used account +* allow to view password (after entering system secret) +* device update message is added as unread only for the first account +* share log to other chats or apps +* use colors for info/warning/error in the log +* fix: preserve upper-/lowercase of links from HTML-messages +* fix: rescan folders on "Watch Sent Folder" changes +* fix sometimes wrong sender name in "Message Info" +* fix: do not send avatar in securejoin messages before contact verification +* fix: avoid being re-added to groups just left +* fix: do not auto-delete webxdc apps that have recent updates +* fix: improve moving messages on gmail +* fix: improve chat assignments of not downloaded messages +* fix: do not create ad-hoc groups from partial downloads +* fix: improve connectivity on startup by adding backoff for IMAP connections +* fix: mark contact request messages as seen on IMAP server +* fix: convert images to RGB8 before encoding into JPEG to fix sending of large RGBA images +* fix showing large PNG files +* fix: do not convert large GIF to JPEG +* fix receiving Autocrypt Setup Messages from K-9 +* fix: delete expired locations and POIs with deleted chats +* fix: send locations more reliable +* fix: use last known location if it is recent enough +* fix: do not fail to send encrypted quotes to unencrypted chats, replace quote by "..." instead +* fix: always use correct "Saved Messages" icon when the chat is recreated +* fix: add white background to transparent avatars +* fix crashes when exporting or importing huge accounts +* fix: remove leading whitespace from subject +* fix problem with sharing the same key by several accounts +* fix busy looping eg. during key import +* fix remote group membership changes always overriding local ones +* fix hint when adding a webxdc shortcut to the home page +* fix webxdc links for securejoin +* fix sending uncompressed images (bug introduced in beta 1.45 beta series) +* fix: hide not useful menu options in the QR screens +* fix scanning invite codes from the "New Chat" screen +* fix: use the last header of multiple ones with the same name; this is the one DKIM was using +* fix migration of legacy databases +* fix: on onboarding, keep entered name and avatar when scanning QR codes or going for other options +* fix broken "..." ellipsis for small screens +* fix: do not mark the message with locations as seen +* fix startup crash on android4 +* fix location streaming crash introduced in 1.45 beta +* update translations and local help +* update to core 1.139.5 + + +## v1.44.0 +2024-03 + +* sync self-avatar and self-signature text across devices +* remove webxdc sending limit +* recognize "Trash" folder by name in case it is not flagged as such by the server +* send group avatars inline so that they do not appear as unexpected attachments +* "Settings / Advanced / Send statistics to Delta Chat's developers" + now include number of protected/encrypted/unencrypted chats +* fix sending sync messages on updating self-name etc. +* fix sometimes slow reconnects +* more bug fixes +* update translations and local help +* update to core 1.136.2 + + +## v1.43.1 Testrun +2024-02 + +* add "Settings / Advanced / Send statistics to Delta Chat's developers" to draft a message with statistic; + the message is only sent if the user hits the "Send" button +* add device message if outgoing messages are undecryptable +* "Settings / Advanced / Read System Address Book" is remembered per-account +* add link to troubleshooting for "Add as Second Device" on welcome screen and update troubleshooting +* fix compatibility issue with 1.42 when using "Add Second Device" or backups +* fix sometimes mangled links +* fix sometimes wrongly marked gossiped keys +* fix: guarantee immediate message deletion if "Delete Messages from Server" is set to "At once" +* fix: Never allow a message timestamp to be a lot in the future +* fix: make IMAP folder handling more resilient +* update translations and local help +* update to core 1.135.0 + + +## v1.43.0 Testrun +2024-02 + +* add "Reactions": long tap a message to react to it ❤️ +* reactions from others are shown below the messages +* tap a reaction below a message to get reaction details +* sharing QR code now shares "Invite Link": + if tapped by with Delta Chat users, Delta Chat opens; otherwise the browser opens; + the server does not get any information about the link details (as "Fragment" is not sent to server) +* copying/pasting QR code data now also supports invite links +* when using multiple accounts, + the avatar in the upper left corner now shows the number of unread messages in other account +* updated "welcome message" now focuses about how to get in contact +* add meaningful info message if provider does not allow unencrypted messages +* long-tapping chatlist items now allow to mute/unmute chats directly +* ask for system unlock secret before opening "Password & Account" +* add 'Learn More' to ephemeral messages dialog +* mark data as being "fragile", supporting systems now allows the data to be kept, making reinstalls easier +* new option "Settings / Advanced / Read System Address Book": + when enabled, the address book addresses are added to the "New Chat" activity +* faster reconnects when switching from a bad or offline network to a working network +* add "From:" to protected headers for signed-only messages generated by some apps +* sync user actions for ad-hoc groups across devices +* sync contact creation/rename across devices +* encrypt read receipts +* only try to configure non-strict TLS checks if explicitly set +* accept i.delta.chat as well as openpgp4fpr: links +* force a display name to be set when using an instant onboarding QR code +* focus on name and state for guaranteed e2ee chats; email address and other data are available in the profile +* improve navigation on account creation by adding a title and a back button to the welcome screen +* fix: delete resent messages on receiver side +* fix: do not drop unknown report attachments, such as TLS reports +* fix: be graceful with systems mangling the qr-code-date (macOS, iOS) +* fix unexpected line breaks in messages (by using Quoted-Printable MIME) +* fix: avoid retry sending for servers not returning a response code in time (force BCC-self) +* fix partially downloaded messages getting stuck in "Downloading..." +* fix inconsistent QR scan states (track forward and backward verification separately, mark 1:1 chat as verified as early as possible) +* fix duplicated messages for some providers as "QQ Mail" +* fix: do not remove contents from unencrypted Schleuder mailing lists messages +* fix: reset message error when scheduling resending +* fix marking some one-to-one chats as guaranteed +* fix: avoid multiple resending of messages on slow SMTP servers +* fix: more reliable connectivity information +* fix: delete received outgoing messages from SMTP queue +* fix timestamp of guaranteed e2ee info message for correct message ordering after backup restore +* fix: add padlock to empty part if the whole message is empty +* fix IDLE timeout renewal on keepalives and reduce it to 5 minutes +* fix: fail fast on LIST errors to avoid busy loop when connection is lost +* fix: improve checking if all members of a chat are verified +* fix: same "green checkmark" message order on all platforms +* fix CI by increasing TCP timeouts from 30 to 60 seconds +* update translations and local help +* update to core 1.134.0 + + +## v1.42.6 +2023-11 + +* sync changes on "Your Profile Name", "Show Class Mails", "Read Receipts" options across devices +* remove receiver limit on .xdc size +* fix decryption errors when using multiple private keys +* fix more log in errors for providers as 163.com; this was introduced in 1.41 +* fix: database locked errors on webxdc updates +* update translations and local help +* update to core 1.131.9 + + +## v1.42.4 +2023-11 + +* fix battery draining due to active IMAP loop on some providers; this was introduced in 1.41 +* fix log in error on some providers as 163.com; this was introduced in 1.41 +* fix "Learn More" buttons that opened the help always in english +* update local help +* update to core 1.131.7 + + +## v1.42.3 +2023-11 + +* fix: avoid infinite loop by failing fast on IMAP FETCH parsing errors +* update translations +* update to core 1.131.6 + + +## v1.42.2 +2023-11 + +* fix contact creation using outdated names sometimes +* fix: do not replace the message with an error in square brackets + when the sender is not a member of the protected group +* fix: compare addresses on QR code scans and at similar places case-insensitively +* fix: normalize addresses to lower case to catch unrecoverable typos and other rare errors +* fix: fetch contact addresses in a single query +* fix: sync chat name to other devices +* update translations and local help +* update to core 1.131.5 + + +## v1.42.1 +2023-11 + +* fix "Member added" message not being a system message sometimes +* update translations and local help +* update to core 1.131.4 + + +## v1.42.0 +2023-11 + +* fix download button shown when download could be decrypted +* using core 1.131.3 + + +## v1.41.9 Testrun +2023-11 + +* fix missing messages because of misinterpreted server responses (ignore EOF on FETCH) +* fix: re-gossip keys if a group member changed setup +* fix: skip sync when chat name is set to the current one +* fix: ignore unknown sync items to provide forward compatibility + and to avoid creating empty message bubbles in "Saved Messages" +* update translations and local help +* update to core 1.131.3 + + +## v1.41.8 Testrun +2023-11 + +* use local help for guaranteed end-to-end encryption "Learn More" links +* do not post "NAME verified" messages on QR scan success +* improve system message wording +* fix: allow to QR scan groups when 1:1 chat with the inviter is a contact request +* fix: add "Setup Changed" message before the message +* fix: read receipts created or unblock 1:1 chats sometimes +* add Vietnamese translation, update other translations and local help +* update to core 1.131.2 + + +## v1.41.7 Testrun +2023-11 + +* synchronize "Broadcast Lists" (experimental) across devices +* add "Scan QR Code" button to "New Chat / New Contact" dialog +* fix: do not skip actual message parts when group change messages are inserted +* fix broken chat names (encode names in the List-ID to avoid SMTPUTF8 errors) +* update translations +* update to core 1.131.1 + + +## v1.41.6 Testrun +2023-11 + +* simplify adding new contacts: "New Chat / Add Contact" button is now always present +* add a QR icon beside the "Show QR invite code" option +* add info messages about implicitly added members +* improve handling of various partly broken encryption states by adding a secondary verified key +* fix: mark 1:1 chat as protected when joining a group +* fix: raise lower auto-download limit to 160k +* fix: remove Reporting-UA from read receipt +* fix: do not apply group changes to special chats; avoid adding members to trashed chats +* fix: protect better against duplicate UIDs reported by IMAP servers +* fix more cases for the accidentally hidden title bar on android14 +* update provider database +* update translations +* update to core 1.130.0 + + +## v1.41.5 Testrun +2023-11 + +* sync Accept/Blocked, Archived, Pinned and Mute across devices +* add "group created instructions" as info message to new chats +* clone group in the group's profile menu +* add hardcoded fallback DNS cache +* improve group creation and make it more obvious that a group is created +* auto-detect if a group with guaranteed end-to-end encryption can be created +* more graceful ratelimit for .testrun.org subdomains +* faster message detection on the server +* fix accidentally hidden title bar on android14 +* fix: more reliable group consistency by always automatically downloading messages up to 160k +* fix: properly abort backup process if there is some failure +* fix: make sure, a QR scan succeeds if there is some leftover from a previously broken scan +* fix: allow other guaranteed e2ee group recipients to be unverified, only check the sender verification +* fix: switch to "Mutual" encryption preference on a receipt of encrypted+signed message +* fix hang in receiving messages when accidentally going IDLE +* fix: allow verified key changes via "member added" message +* fix: partial messages do not change group state +* fix: don't implicitly delete members locally, add absent ones instead +* update translations +* update to core 1.129.1 + + +## v1.41.3 Testrun +2023-10 + +* allow to export all backups together +* "New Group" offers to create verified groups if all members are verified +* verified groups: show all contacts when adding members and explain how to verify unverified ones +* "QR Invite Code" is available after group creation in the group's profile +* update translations +* using core 1.127.2 + + +## v1.41.2 Testrun +2023-10 + +* guarantee end-to-end-encryption in one-to-one chats, if possible +* if end-to-end-encryption cannot be guaranteed eg. due to key changes, + the chat requires a confirmation of the user +* "verified groups" are no longer experimental +* backup filenames include the account name now +* "Broadcast Lists" (experimental) create their own chats on the receiver site +* tapping the title bar always opens account switcher; from there you can open connectivity +* add "Deactivate QR code" option when showing QR codes + (in addition to deactivate and reactivate QR codes by scanning them) +* show name and e-mail address of verifiers +* fix stale app on configuration screen if DNS is not available +* fix: keep showing old email address if configuring a new one fails +* fix starting chats from the system's phone app (by improving mailto: handling) +* fix unresponsiveness when opening "Connectivity View" when offline +* fix configure error with "Winmail Pro Mail Server" +* fix: set maximal memory usage for the internal database +* fix: allow setting a draft if verification is broken +* fix joining verified group via QR if contact is not already verified +* fix: sort old incoming messages below all outgoing ones +* fix: do not mark non-verified group chats as verified when using securejoin +* fix: show only chats where we can send to on forwarding or sharing +* fix: improve removing accounts in case the filesystem is busy +* fix: don't show a contact as verified if their key changed since the verification +* update translations +* update to core 1.127.2 + + +## v1.41.1 Testrun +2023-10 + +* tweak action bar color in dark mode +* fix asking for permissions on Android 11; these bugs were introduced by 1.41.0 +* fix chatlist showing sometimes chats from other accounts after clicking notifications +* fix crash when clicking a notification sometimes +* fix crash when selecting a background image sometimes +* fix long-taps on audio message's controls in multi-select mode +* fix dark mode's color of "encrypt" checkbox in welcome screen +* fix sorting error with downloaded manually messages +* fix group creation when the initial group message is downloaded manually +* fix connectivity status view for servers not supporting IMAP IDLE +* fix: don't try to send more read receipts if there's a temporary SMTP error +* fix "Verified by" information showing an error instead of the verifier sometimes +* update translations +* update to core 1.125.0 + + +## v1.41.0 Testrun +2023-10 + +* keep screen on while playing voice messages +* pause background music when starting voice messages +* use the system camera as default; the old built-in camera can be enabled at "Settings / Advanced" +* add "Verified by" information to contact profiles +* screen reader: read out message types +* screen reader: allow tapping anywhere in the message to start voice or audio playback +* set different wallpapers for different accounts +* add "Select All" to gallery and to file lists +* resend attachments from profile (long tap, then "Resend" in the menu) +* allow to import a key file instead of a folder containing keys +* search in "Attach Contact" dialog +* improve landscape mode for webxdc apps +* adapt webxdc loading screen to dark mode +* add file name to dialog shown if a webxdc app wants to share information +* add app icon to webxdc info messages and improve webxdc app icon layout +* improve layout of input bar when system emojis are used +* ask for permissions before adding notifications on Android 13 (needed by the required update to API 33) +* switch account if needed when opening webxdc app on the system's home screen +* improve video error messages and logging +* fix sometimes wrong avatar shown in notifications when using multiple accounts +* fix: save map preferences per account to avoid resetting location and zoom +* fix: play audio and voice messages: do not show progress in unrelated messages +* fix: update relative times directly after entering chatlist, do not wait for a minute +* fix issues when after selecting a non-system-language, system-language strings still show up +* fix: only jump to message if info message is from webxdc +* fix: update webxdc document name in titles immediately +* fix: do not open Connectivity when tapping forward/share titles +* fix starting conversation with contact from the phone contacts app +* fix WASM support for some webxdc apps +* fix off-by-one mismatch in manual language selection +* fix: sanitize invalid filename we get from some camera apps +* fix: display sticker footer properly +* fix: webxdc apps starting twice sometimes +* fix sending images and other files in location steaming mode +* fix connectivity view layout if eg. storage shows values larger than 100% +* fix scanning account-QR-codes on older phones that miss the Let's Encrypt system certificate +* fix: make Thunderbird show encrypted subjects +* fix: do not forward document name when forwarding only a webxdc app +* fix: do not create new groups if someone replies to a group message with status "failed" +* fix: do not block new group chats if 1:1 chat is blocked +* fix "Show full message" showing a black screen for some messages received from Microsoft Exchange +* fix: skip read-only mailing lists from forwarding/share chat lists +* fix: do not allow dots at the end of email addresses +* fix: do not send images pasted from the keyboard unconditionally as stickers +* fix: forbid membership changes from possible non-members, allow from possible members +* fix: improve group consistency across members +* fix: delete messages from SMTP queue only on user demand +* fix: improve wrapping of email messages on the wire +* fix memory leak in IMAP +* update translations and local help +* update to core 1.124.1 + + +## v1.40.1 +2023-08 + +* fix: correct core-submodule picked up by f-droid +* update to core119.1 + + +## v1.40.0 +2023-08 + +* use image editor for avatar selection when possible +* allow media from blob: and data: in webxdc +* optimized native library size +* improve loading screen in dark mode +* improve IMAP logs +* update "verified icon" +* fix webxdc issues with dark mode +* fix crash in android 4.2 or older when opening a HTML message in full message view +* fix: avoid IMAP move loops when DeltaChat folder is aliased +* fix: accept webxdc updates in mailing lists +* fix: delete webxdc status updates together with webxdc instance +* fix: prevent corruption of large unencrypted webxdc updates +* fix "Member added by me" message appearing sometimes within wrong context +* fix core panic after sending 29 offline messages +* fix: make avatar in qr-codes work on more platforms +* fix: preserve indentation when converting plaintext to HTML +* fix: remove superfluous spaces at start of lines when converting HTML to plaintext +* fix: always rewrite and translate member added/removed messages +* add Luri Bakhtiari translation, update other translations and local help +* update to core119 + + +## v1.38.2 +2023-06 + +* fix version code issue with google play +* using core117.0 + + +## v1.38.1 +2023-06 + +* update translations +* using core117.0 + + +## v1.38.0 +2023-06 + +* improve group membership consistency +* fix verification issues because of email addresses compared case-sensitive sometimes +* fix empty lines in HTML view +* fix empty links in HTML view +* fix displaying of smaller images that were shown just white sometimes +* fix android4 HTML view; bug introduced in v1.37.0 +* update translations +* update to core117.0 + + +## v1.37.0 Testrun +2023-06 + +* new webxdc APIs: importFiles() and sendToChat() +* remove upper size limit of attachments +* save local storage: compress HTML emails in the database +* save traffic and storage: recode large PNG and other supported image formats + (large JPEG were always recoded; images send as "File" are still not recorded or changed otherwise) +* also strip metadata from images before sending + in case they're already small enough and do not require recoding +* strip unicode sequences that are useless but may trick the user (RTLO attacks) +* set a draft when scanning a QR code containing compatible mailto: data +* tweak colors: make titles more visible in dark mode +* bigger scroll-to-bottom button +* fix appearance of verified icons +* fix some bugs with handling of forward/share views +* fix: exiting messages are no longer downloaded after configuration +* fix: don't allow blocked contacts to create groups +* fix: do not send messages when sending was cancelled while being offline +* fix various bugs and improve logging +* update to core116.0 + + +## v1.36.5 +2023-04 + +* use SOCKS5 configuration also for loading remote images in HTML mails +* bug fixes +* update translations and local help +* update to core112.8 + + +## v1.36.4 +2023-04 + +* start with light/dark theme depending on system theme +* fix verification icons for one-to-one chats +* fix fetch errors due to erroneous EOF detection in long IMAP responses +* more bug fixes +* update translations and local help +* update to core112.7 + + +## v1.36.2 +2023-04 + +* add a device message after setting up a second device +* speed up "Add as Second Device" connection time significantly on the getter side +* if possible, show Wi-Fi-name directly after scanning an "Add Second Device" QR code +* fix immediate restarts of "Add Second Device" +* fix: do not show just trashed media in "All Media" view +* fix: update database if needed after "Add Second Device" +* update translations and local help +* update to core112.6 + + +## v1.36.0 +2023-03 + +* new, easy method of adding a second device to your account: + select "Add as Second Device" after installation and scan a QR code from the old device +* view "All Media" of all chats by the corresponding option in the chat list's menu +* add "Clear Chat" option to remove all messages from a chat +* show non-deltachat emails by default for new installations + (you can change this at "Settings / Chats and Media) +* show notifications for all accounts +* make better use of dark/light mode in "Show full message" +* show icon beside info messages of apps +* resilience against outages by caching DNS results for SMTP connections + (IMAP connections are already cached since 1.34.11) +* prefer TLS over STARTTLS during autoconfiguration, set minimum TLS version to 1.2 +* use SOCKS5 configuration also for HTTP requests +* make invite QR codes even prettier +* improve speed by reorganizing the database connection pool +* improve speed by decrypting messages in parallel +* improve reliability by using read/write instead of per-command timeouts for SMTP +* improve reliability by closing databases sooner +* improve compatibility with encrypted messages from non-deltachat clients +* fix: Skip "Show full message" if the additional text is only a footer already shown in the profile +* fix verifications when using for multiple devices +* fix backup imports for backups seemingly work at first +* fix a problem with gmail where (auto-)deleted messages would get archived instead of deleted +* fix deletion of more than 32000 messages at the same time +* update provider database +* update translations and local help +* update to core112.1 + + +## v1.34.13 +2023-02 + +* fix sending status updates of private apps +* show full messages: do not load remote content for requests automatically +* using core107.1 + + +## v1.34.12 +2023-02 + +* disable SMTP pipelining for now +* fix various bugs and improve logging +* update translations +* update to core107.1 + + +## v1.34.11 +2023-01 + +* add SOCKS5 options to "Add Account" and "Configure" +* introduce DNS cache: if DNS stops working on a network, + Delta Chat will still be able to connect to IMAP by using previous IP addresses +* speed up sending and improve usability in flaky networks by using SMTP pipelining +* fix SOCKS5 connection handling +* fix various bugs and improve logging +* update translations +* update to core107 + + +## v1.34.10 +2023-01 + +* fix: make archived chats visible that don't get unarchived automatically (muted chats): + add an unread counter and move the archive to the top +* fix: send AVIF, HEIC, TXT, PPT, XLS, XML files as such +* fix: trigger reconnection when failing to fetch existing messages +* fix: do not retry fetching existing messages after failure, prevents infinite reconnection loop +* fix: do not add an error if the message is encrypted but not signed +* fix: do not strip leading spaces from message lines +* fix corner cases on sending quoted texts +* fix STARTTLS connection +* fix: do not treat invalid email addresses as an exception +* fix: flush relative database paths introduced in 1.34.8 in time +* faster updates of chat lists and contact list +* update translations +* update to core106 + + +## v1.34.8 +2022-12 + +* If a classical-email-user sends an email to a group and adds new recipients, + the new recipients will become group members +* treat attached PGP keys from classical-email-user as a signal to prefer mutual encryption +* treat encrypted or signed messages from classical-email-user as a signal to prefer mutual encryption +* fix migration of old databases +* fix: send ephemeral timer change messages only of the chat is already known by other members +* fix: use relative paths to database and avoid problems eg. on migration to other devices or paths +* fix read/write timeouts for IMAP over SOCKS5 +* fix: do not send "group name changes" if no character was modified +* add Greek translation, update other translations +* update to core104 + + +## v1.34.7 Testrun +2022-12 + +* prevent From:-forgery attacks +* disable Autocrypt & Authres-checking for mailing lists because they don't work well with mailing lists +* small speedups +* improve logging +* fix detection of "All mail", "Trash", "Junk" etc folders +* fix reactions on partially downloaded messages by fetching messages sequentially +* fix a bug where one malformed message blocked receiving any further messages +* fix: set read/write timeouts for IMAP over SOCKS5 +* update translations +* update to core103 + + +## v1.34.5 +2022-11 + +* allow removal of referenced contacts from the "New Chat" list +* show more debug info in message info +* improve IMAP logging +* show versionCode in log +* fix potential busy loop freeze when marking messages as seen +* fix build issue for F-Droid +* update translations +* update to core101 + + +## v1.34.4 +2022-11 + +* fix opening chats for android4 (bug introduced with 1.34.3) +* fix adding notifications on some android versions +* update translations +* using core98 + + +## v1.34.3 +2022-10 + +* fix Share-to-Delta and calling Delta otherwise for android12 +* using core98 + + +## v1.34.2 Testrun +2022-10 + +* fix messages not arriving on newer androids by switching to more modern APIs +* fix "recently seen" indicator for right-to-left languages +* fix message bubble corner for right-to-left languages +* fix: suppress welcome messages after account import +* fix: apply language changes to all accounts +* update dependencies and set targetSdkVersion to 32 +* update translations and local help +* update to core98 + + +## v1.34.1 +2022-10 + +* more visible "recently seen" indicator +* fix: hide "disappearing messages" options for mailing lists +* update translations +* using core95 + + +## v1.34.0 Testrun +2022-10 + +* start using "Private Apps" as a more user friendly term for the technical "Webxdc" term +* add "Private Apps" to the home screen from the app's menu, + allowing easy access and integration with "normal" apps +* "Private Apps" and "Audio" are shown as a separate tabs in chat profile +* show a "recently seen" dot on avatars if the contact was seen within ten minutes +* order contact and members lists by "last seen" +* show mailing list addresses in profile +* user friendlier system messages as "You changed the group image." +* introduce a "Login" QR code that can be generated by providers for easy log in +* allow scanning of "Accounts" and "Logins" QR codes using supported system cameras +* truncate incoming messages by lines instead of just length +* for easier multi device setup, "Send Copy To Self" is enabled by default now +* fix: hide "Resend" option for messages that cannot be resent +* fix: hide "Leave group" option for mailing lists +* fix: mark "group image changed" as system message on receiver side +* fix: improved error handling for account setup from QR code +* fix: do not emit notifications for blocked chats +* fix: show attached .eml files correctly +* fix: don't prepend the subject to chat messages in mailing lists +* fix: reject private app updates from contacts who are not group members +* update translations +* update to core95 + + +## v1.32.0 +2022-07 + +* update Maplibre +* update translations +* using core90 + + +## v1.31.1 Testrun +2022-07 + +* AEAP: show confirmation dialog before changing e-mail address +* AEAP: add a device message after changing e-mail address +* AEAP replaces e-mail addresses only in verified groups for now +* fix: handle updates for not yet downloaded webxdc instances +* fix: better information on several configuration and non-delivery errors +* update translations, revise english source +* update to core90 + + +## v1.31.0 Testrun +2022-07 + +* experimental "Automatic E-mail Address Porting" (AEAP): + You can configure a new address now, and when receivers get messages + they will automatically recognize your moving to a new address +* combine read receipts and webxdc updates and avoid sending too many messages +* message lines starting with `>` are sent as quotes to non-Delta-Chat clients +* support IMAP ID extension that is required by some providers +* forward info messages as plain text +* allow mailto: links in webxdc +* fix: allow sharing filenames containing the character `~` +* fix: allow DeltaChat folder being hidden +* fix: cleanup read receipts storage +* fix: mailing list: remove square-brackets only for first name +* fix: do not use footers from mailinglists as the contact status +* update to core88 + + +## v1.30.3 +2022-06 + +* cleanup series of webxdc-info-messages +* fix: make chat names always searchable +* fix: do not reset database if backup cannot be decrypted +* fix: do not add legacy info-messages on resending webxdc +* fix: webxdc "back" button always closes webxdc +* fix: let "Only Fetch from DeltaChat Folder" ignore other folders +* fix: Autocrypt Setup Messages updates own key immediately +* fix: do not skip Sent and Spam folders on gmail +* fix: cleanup read-receipts saved by gmail to the Sent folder +* fix: handle decryption errors explicitly and don't get confused by encrypted mail attachments +* update provider database, add hermes.radio subdomains +* update translations +* update to core86 + + +## v1.30.2 +2022-05 + +* show document and chat name in webxdc titles +* add menu entry access the webxdc's source code +* remove anyway unused com.google.android.gms from binary to avoid being flagged +* send normal messages with higher priority than read receipts +* improve chat encryption info, make it easier to find contacts without keys +* improve error reporting when creating a folder fails +* fix: repair encrypted mails "mixed up" by Google Workspace "Append footer" function +* fix: use same contact-color if email address differ only in upper-/lowercase +* update translations +* update to core83 + + +## v1.30.1 +2022-05 + +* fix wrong language in read receipts +* fix encoding issue in QR code descriptions +* webxdc: allow internal pages +* update translations and local help +* update provider database +* update to core80 + + +## v1.30.0 +2022-05 + +* speed up loading of chat messages by a factor of 20 +* speed up finding the correct server after logging in +* speed up marking messages as being seen and use fewer network data by batch processing +* speed up messages deletion and use fewer network data for that +* speed up webxdc parsing by not loading the whole file into memory +* speed up message receiving a bit +* speed up chat list +* speed up opening chat +* speed up various parts by caching config values +* revamped welcome screen +* archived+muted chats are no longer unarchived when new messages arrive; + this behavior is also known by other messengers +* warn when enabling "Only Fetch from DeltaChat Folder" +* fix: do not create empty contact requests with "setup changed" messages; + instead, send a "setup changed" message into all chats we share with the peer +* fix an issue where the app crashes when trying to export a backup +* fix outgoing messages appearing twice with Amazon SES +* fix unwanted deletion of messages that have no Message-ID set or are duplicated otherwise +* fix: assign replies from a different email address to the correct chat +* fix: assign outgoing private replies to the correct chat +* fix: ensure ephemeral timer is started eventually also on rare states +* fix: do not try to use stale SMTP connections +* fix: retry message sending automatically and do not wait for the next message being sent +* fix a bug where sometimes the file extension of a long filename containing a dot was cropped +* fix messages being treated as spam by placing small MIME-headers before the larger Autocrypt:-header +* fix: keep track of QR code joins in database to survive restarts +* fix: automatically accept chats with outgoing messages +* fix connectivity view's "One moment..." message being stuck when there is no network +* fix wrong avatar rotation when selecting self-avatar from gallery +* fix wrong font size in app title +* fix quitting app when forwarding on android4 and android11+ +* fix emojis on android4 +* fix: do not disable fullscreen keyboard +* fix: mark messages as seen more reliable and faster +* fix sound notifications, allow to set to "silent" +* fix ux issue in the forward dialog +* fix: update search results when the chatlist changes +* fix: show download failures +* fix sending webxdc via share-to-delta +* fix potential webxdc id collision +* fix: send locations in the background regardless of other sending activity +* fix rare crashes when stopping IMAP and SMTP +* fix correct message escaping consisting of a dot in SMTP protocol +* fix: don't jump to parent message if parent message is not a webxdc +* fix webxdc background mode so that music stops playing +* webxdc: improve display of webxdc items in the gallery's "docs" tab +* webxdc: show icon in quotes +* webxdc: long-tap on a message allows resending own messages +* webxdc: allow sessionStorage, localStorage and IndexedDB +* webxdc: remove getAllUpdates(), setUpdateListener() improved +* webxdc: option to set minimal API in the manifests +* add finnish translation, update other translations +* update to core79 + + +## v1.28.3 +2022-02 + +* faster message moving and deletion on the server +* parse MS Exchange read receipts and mark the original message as read +* fix a bug where messages in the Spam folder created contact requests +* fix a bug where drafts disappeared after some days +* fix: do not retry message sending infinitely in case of permanent SMTP failure +* fix: set message state to failed when retry limit is exceeded +* fix: avoid archived, fresh chats +* update translations +* update to core76 + + +## v1.28.1 +2022-02 + +* update translations, thanks a lot to all translators, + porting Delta Chat to so many languages <3 + + +## v1.28.0 +2022-01 + +* add option "Advanced / Only Fetch from DeltaChat Folder"; + this is useful if you can configure your server to move chat messages to the DeltaChat folder +* to safe traffic and connections, "Advanced / Watch Sent Folder" is disabled by default; + as all other IMAP folders, the folder is still checked on a regular base +* fix: use Webxdc name in chatlist, quotes and drafts +* fix splitting off text from Webxdc messages +* fix: show correct Webxdc summary on drafts +* fix: speed up folder scanning +* fix: make it possible to cancel message sending by removing the message; + this was temporarily impossible since 1.27.0 +* fix: avoid endless reconnection loop +* fix display of qr-group-invite code text +* update translations +* update provider-database +* update to core75 + + +## v1.27.2 Testrun Release +2022-01 + +* improve Webxdc bubble layout +* async Webxdc API and reworked Webxdc properties +* fix: do not share cached files between Webxdc's +* fix: do not force dark mode for Webxdc and HTML-messages + + +## v1.27.1 Testrun Release +2022-01 + +* fix backup import issue introduced in 1.27.0 +* update to core72 + + +## v1.27.0 Testrun Release +2022-01 + +* add option to create encrypted database at "Add Account / Advanced", + the database passphrase is generated automatically and is stored in the system's keychain, + subsequent versions will probably get more options to handle passphrases +* add experimental support for Webxdc extensions +* add "Advanced / Developer Mode" to help on creating Webxdc extensions +* add writing support for supported mailinglist types; other mailinglist types stay read-only +* "Message Info" show routes +* explicit "Watch Inbox folder" and "Watch DeltaChat folder" settings no longer required; + the folders are watched automatically as needed +* detect correctly signed messages from Thunderbird and show them as such +* synchronize Seen status across devices +* more reliable group memberlist and group avatar updates +* recognize MS Exchange read receipts as such +* fix leaving groups +* fix unread count issues in account switcher +* fix crash when selecting thumbnail image +* fix add POI if the user cannot send in a chat +* fix "Reply Privately" in contact request chats +* add Bulgarian translations, update other translations and local help +* update provider-database +* update to core71 + + +## v1.26.2 +2021-12 + +* re-layout all QR codes and unify appearance among the different platforms +* show when a contact was "Last seen" in the contact's profile +* group creation: skip presetting a draft that is deleted most times anyway +* display auto-generated avatars and unread counters similar across platforms +* fix chat assignment when forwarding +* fix layout bug in chatlist title +* fix crashes when opening map +* fix group-related system messages appearing as normal messages in multi-device setups +* fix removing members if the corresponding messages arrive disordered +* fix potential issue with disappearing avatars on downgrades +* fix log in failures for "Google Workspace" (former "G Suite") addresses using oauth2 +* switch from Mapbox to Maplibre +* update translations +* update to core70 + + +## v1.24.4 +2021-11 + +* fix accidental disabling of ephemeral timers when a message is not auto-downloaded +* fix: apply existing ephemeral timer also to partially downloaded messages; + after full download, the ephemeral timer starts over +* update translations and local help +* update to core65 + + +## v1.24.3 +2021-11 + +* fix crash when exporting several attachments at the same time +* fix messages added on scanning the QR code of an contact +* fix incorrect assignment of Delta Chat replies to classic email threads +* add basic support for remove account creation +* update translations and local help + + +## v1.24.2 +2021-11 + +* show the currently selected account in the chatlist; + a tap on it shows the new, improved account selector dialog +* new option "Auto-Download Messages": Define the max. messages size to be downloaded automatically - + larger messages, as videos or large images, can be downloaded manually by a simple tap then +* long tap the app icon to go directly to one of the recent chats + (requires Android 7 and a compatible launcher) +* much more QR code options: copy, paste, save as image, import from image +* new: much easier joining of groups via qr-code: nothing blocks + and you get all progress information in the immediately created group +* new: get warnings before your server runs out of space (if quota is supported by your provider) +* messages are marked as "being read" already when the first recipient opened the message + (before, that requires 50% of the recipients to open the message) +* contact requests are notified as usual now +* force strict certificate checks when a strict certificate was seen on first login +* do not forward group names on forwarding messages +* "Broadcast Lists", as known from other messengers, added as an experimental feature + (you can enable it at "Settings / Advanced") +* improve accessibility: add some button descriptions +* remove "view profile" from the chat menu; just tap the chat name to open the profile +* accept contact request before replying from notification +* improve selected recipients list on group creation +* from within a contact's profile, offer group creation with that contact ("New Group or Subject") +* fix: disappearing messages timer now synced more reliable in groups +* fix: improve detection of some mailing list names +* fix "QR process failed" error +* fix DNS and certificate issues +* fix: if account creation was aborted, go to the previously selected account, not to the first +* fix back button not working in connectivity view sometimes +* fix: disable chat editing options if oneself is not a member of the group +* fix shared image being set as draft repeatedly +* fix: hide keyboard when compose panel is hidden +* fix "jump to section" links html-messages +* fix: allow to select audio files in multi-select mode in Docs tab +* fix fullscreen input issues by disabling this mode +* fix: group creating: don't add members if back button is pressed +* update provider-database +* update translations and local help + + +## v1.22.1 +2021-08 + +* update translations + + +## v1.22.0 +2021-08 + +* added: connectivity view shows quota information, if supported by the provider +* fix editing shared images +* fix account migration, updates are displayed instantly now +* fix forwarding mails containing only quotes +* fix ordering of some system messages +* fix handling of gmail labels +* fix connectivity display for outgoing messages +* update translations and provider database + + +## v1.21.2 Testrun Release +2021-08 + +* fix: allow dotless email address being added to groups +* fix: keep selection when migrating several accounts +* fix crash when going back to the chatlist +* update translations + + +## v1.21.1 Testrun Release +2021-08 + +* fix: avoid possible data loss when the app was not closed gracefully before; + this bug was introduced in 1.21.0 and not released outside testing groups - + thanks to all testers! + + +## v1.21.0 Testrun Release +2021-08 + +* added: every new "contact request" is shown as a separate chat now, + you can block or accept or archive or pin them + (old contact requests are available in "Archived Chats") +* added: the title bar shows if the app is not connected +* added: a tap in the title bar shows connectivity details (also available in settings) +* deactivate and reactivate your own QR codes by just scanning them +* when using multiple accounts, the background-accounts now also fetch messages + that way, account switching is much faster than before + and the destination account is usually directly usable +* allow dotless email address and localhost server, + this allows using eg. yggmail addresses +* images from "Image keyboards" are sent as stickers now +* unify appearance of user-generated links +* don't open chat directly if user clicks in blocked addresses +* let openpgp4fpr:-links work in html-messages +* speedup chatlist while messages are downloaded +* fix: make log view's scroll to top/bottom work +* fix sharing files with "%" in their name +* fix: welcome-screen respects dark mode now +* fix: html-views respect app/system theme +* fix: hide unnecessary controls if you can't send in a chat +* fix: disable location service if it is not used anymore + + +## v1.20.5 +2021-06 + +* fix downscaling images +* fix outgoing messages popping up in "Saved messages" for some providers +* fix: do not allow deleting contacts with ongoing chats +* fix: ignore drafts folder when scanning +* fix: scan folders also when inbox is not watched +* fix: adapt attached audio's background to theme +* fix: request composer's focus after recording is done +* fix sharing messages with attachments +* fix highlighting messages in search results +* fix: set correct navigation bar color in dark mode +* fix: use the same emoji theme throughout the app +* in in-chat search, start searching at the most recent message +* improve error handling and logging +* remove screen lock as announced in v1.14.0 +* update translations and provider database + + +## v1.20.2 +2021-05 + +* fix crash when receiving some special messages +* fix downloading some messages multiple times +* fix formatting of read receipt texts +* update translations + + +## v1.20.1 +2021-05 + +* improved accessibility and screen reader support +* use the same emoji style everywhere across the app +* allow to select and copy text from "message details" and error dialogs +* show hints about how location data are used +* fix: don't collapse search menu on group changes +* add Indonesian, Polish, Ukrainian local help, update other translations + + +## v1.19.2 Preview Release +2021-04 + +* opening the contact request chat marks all contact requests as noticed + and removes the sticky hint from the chatlist +* if "Show classic mails" is enabled, + the contact request hint in the corresponding chat +* speedup global search +* show system message status while sending and on errors +* improve quote style when replying with a sticker +* fix clicks on system messages +* fix sticker scaling +* fix: disable "reply privately" in contact requests chat + + +## v1.19.1 Preview Release +2021-04 + +* show answers to generic support-addresses as info@example.com in context +* allow different sender for answers to support-addresses as info@example.com +* add APNG and animated webp support +* allow videochat-invites for groups +* let stickers handle taps +* add more options to Gallery and Documents long-tap menus +* allow to add POI with text of any length +* improve detection of quotes +* ignore classical mails from spam-folder +* hide share button in media previews, draft images or avatars +* fix crash when profile tabs are changed during some items are selected +* add Czech translation, update other translations +* add Chinese and French local help, update other local helps + + +## v1.17.0 Preview Release +2021-04 + +* new mailinglist and better bot support +* add option to view original-/html-mails +* check all imap folders for new messages from time to time +* use more colors for user avatars +* improve e-mail compatibility +* improve compatibility with Outlook.com + and other providers changing message headers +* swipe up the voice message record button to lock recording +* show stickers as such +* show status/footer messages in contact profiles +* scale avatars based on media-quality, fix avatar rotation +* export backups as .tar files +* enable strict TLS for known providers by default +* improve and harden secure join +* new gallery options "Show in chat" and "Share from Delta Chat" +* display forwarded messages in quotes as such +* show name of forwarder in groups +* add chat encryption info +* tweak ephemeral timeout options +* show message delivery errors directly when tapping on a message +* add option to follow system light/dark settings +* better profile and group picture selection by using attachment selector +* make the upper left back button return to chat list even if the keyboard is open +* fix decoding of attachment filenames +* fix: exclude muted chats from notify-badge/dot +* fix: do not return quoted messages from the trash chat +* fix text width for messages with tall images +* fix disappearing drafts +* much more bug fixes +* add Khmer and Kurdish translations, update other translations +* add Czech local help, update other local help + + +## v1.14.5 +2020-11 + +* show impact of the "Delete messages from server" option more clearly +* fix: do not fetch from INBOX if "Watch Inbox folder" is disabled + and do not fetch messages arriving before re-enabling +* fix: do not use STARTTLS when PLAIN connection is requested + and do not allow downgrade if STARTTLS is not available +* update translations + + +## v1.14.4 +2020-11 + +* fix input line height when using system-emojis +* fix crash on receiving certain messages with quotes + + +## v1.14.3 +2020-11 + +* add timestamps to image and video filenames +* fix: preserve quotes in messages with attachments + + +## v1.14.2 +2020-11 + +* make quote animation faster +* fix maybe stuck notifications +* fix: close keyboard when a quotes is opened in another chat +* fix: do not cut the document icon in quotes +* fix: make the the quote dismiss button better clickable again +* update translations + + +## v1.14.1 +2020-11 + +* improve display of subseconds while recording voice messages +* disable useless but confusing forwarding of info-messages +* fix: show image editor "Done" button also on small screen +* fix: show more characters of chat names before truncating +* fix crash in image editor + + +## v1.14.0 +2020-11 + +* new swipe-to-reply option +* disappearing messages: select for any chat the lifetime of the messages +* chat opens at the position of the first unseen message +* add known contacts from the IMAP-server to the local addressbook on configure +* direct forwarding to "saved messages" - save one tap and stay in context :) +* long tap in contact-list allows opening "profile" directly +* allow forwarding to multiple archived chats +* enable encryption in groups if preferred by the majority of recipients + (previously, encryption was only enabled if everyone preferred it) +* add explicit switches for handling background connections + at "Settings / Notifications" +* ask directly after configure for the permission to run in background + to get notifications +* speed up chatlist-view +* speed up configuration +* try multiple servers from autoconfig +* prefix log by a hint about sensitive information +* check system clock and app date for common issues +* prepare to remove screen lock as it adds only few protection + while having issues on its own +* improve multi-device notification handling +* improve detection and handling of video and audio messages +* hide unused functions in "Saved messages" and "Device chat" profiles +* remove unneeded information when copying a single message to the clipboard +* bypass some limits for maximum number of recipients +* fix launch if there is an ongoing process +* fix: update relative times in chatlist once a minute +* fix: hide keyboard when leaving edit-name +* fix: connect immediately to an account scanned from a qr-code +* fix errors that are not shown during configuring +* fix keyboard position on Android Q +* fix mistakenly unarchived chats +* fix: tons of improvements affecting sending and receiving messages, see + https://github.com/deltachat/deltachat-core-rust/blob/master/CHANGELOG.md +* update provider database and dependencies +* add Slovak translation, update other translations + + +## v1.12.5 +2020-08 + +* fix notifications for Android 4 +* fix and streamline querying permissions +* fix removing POIs from map +* fix emojis displayed on map +* fix: connect directly after account qr-scan +* make bot-commands such as /echo clickable + + +## v1.12.3 +2020-08 + +* more generous acceptance of entered webrtc-servers names +* allow importing backups in the upcoming .tar format +* remove X-Mailer debug header +* try various server domains on configuration +* improve guessing message types from extension +* make links in error messages clickable +* fix rotation when taking photos with internal camera +* fix and improve sharing and sendto/mailto-handling +* fix oauth2 issues +* fix threading in interaction with non-delta-clients +* fix showing unprotected subjects in encrypted messages +* more fixes, update provider database and dependencies + + +## v1.12.2 +2020-08 + +* fix improvements for sending larger mails +* fix a crash related to muted chats +* fix incorrect dimensions sometimes reported for images +* improve linebreak-handling in HTML mails +* improve footer detection in plain text email +* define own jitsi-servers by the prefix `jitsi:` +* fix deletion of multiple messages +* more bug fixes + + +## v1.12.1 +2020-07 + +* show a device message when the password was changed on the server +* videochats introduced as experimental feature +* show experimental disappearing-messages state in chat's title bar +* improve sending large messages +* improve receiving messages +* improve error handling when there is no network +* use correct aspect ratio of background images +* fix sending uncompressed images +* fix emojis for android 4 +* more bug fixes + + +## v1.10.5 +2020-07 + +* forward and share to multiple contacts in one step +* disappearing messages added as an experimental feature +* fix profile image selection +* fix blurring +* improve message processing +* improve overall stability + + +## v1.10.4 +2020-06 + +* add device message, summing up changes +* update translations and help + + +## v1.10.3 +2020-06 + +* with this version, Delta Chat enters a whole new level of speed, + messages will be downloaded and sent way faster - + technically, this was introduced by using so called "async-processing" +* avatars can be enlarged +* add simplified login for gsuite email addresses +* new emoji selector - including new and diversified emojis +* you can now "blur" areas in an image before sending +* new default wallpaper +* if a message cannot be delivered to a recipient + and the server replies with an error report message, + the error is shown beside the message itself in more cases +* backup now includes the mute-state of chats +* notifications now use one system-editable channel per chat, + this fix various notification bugs +* android 7 and newer groups notifications +* multi-account is an officially supported feature now +* default to "Strict TLS" for some known providers +* improve reconnection handling +* improve interaction with conventional email programs + by showing better subjects +* allow calling the app from others apps with a standard email intent +* fix issues with database locking +* fix importing addresses +* lots of other fixes + + +## v1.8.1 +2020-05-14 + +* fix a bug that could led to load if the server does not use sent-folder +* fix bug on sharing +* improve polling when background-connection is unreliable +* since 1.6.0, changing group-name removed the group-avatar sometimes, fixed + + +## v1.8.0 +2020-05-11 + +* by default, the permanent notification is no longer shown; + the background fetch reliability depends on the system and the + permanent notification can be enabled at "Settings / Notifications" as needed +* fix a bug that stops receiving messages under some circumstances +* more bug fixes +* update translations + + +## v1.6.2 +2020-05-02 + +* expunge deleted messages more frequently +* bug fixes +* update translations + + +## v1.6.0 +2020-04-29 + +* new options to auto-delete messages from the device or from your server + see "Settings / Chats and media" +* to save traffic and time, smaller and faster Ed25519 keys are used by default +* in-chat search +* search inside the integrated help +* new experimental feature that allows switching the account in use +* improve interaction with traditional mail clients +* improved onboarding when the provider returns a link +* to improve background fetch, show a permanent notification by default +* the permanent notification can be disabled at "Settings / Notifications" +* bug fixes +* add Indonesian and Persian translations, update other translations + + +## v1.3.0 +2020-03-25 + +* on forwarding, "Saved messages" will be always shown at the top of the list +* streamline confirmation dialogs on chat creation and on forwarding to "Saved messages" +* cleanup settings +* improve interoperability eg. with Cyrus server +* fix group creation if group was created by non-delta clients +* fix showing replies from non-delta clients +* fix crash when using empty groups +* several other fixes +* add Sardinian translation, update other translations and help + + +## v1.2.1 +2020-03-04 + +* on log in, for known providers, detailed information are shown if needed; +* in these cases, also the log in is faster + as needed settings are available in-app +* save traffic: messages are downloaded only if really needed, +* chats can now be pinned so that they stay sticky atop of the chat list +* a 'setup contact' qr scan is now instant and works even when offline - + the verification is done in background +* unified 'send message' option in all user profiles +* streamline onboarding +* add an option to create an account by scanning a qr code, of course, + this has to be supported by the used provider +* lower minimal requirements, Delta Chat now also runs on Android 4.1 Jelly Bean +* fix updating names from incoming mails +* fix encryption to Ed25519 keys that will be used in one of the next releases +* several bug fixes, eg. on sending and receiving messages, see + https://github.com/deltachat/deltachat-core-rust/blob/master/CHANGELOG.md#1250 + for details on that +* add Croatian and Esperanto translations, update other translations and help + +The changes have been done by Alexander Krotov, Allan Nordhøy, Ampli-fier, +Angelo Fuchs, Andrei Guliaikin, Asiel Díaz Benítez, Besnik, Björn Petersen, +ButterflyOfFire, Calbasi, cloudieg, Dmitry Bogatov, dorheim, Emil Lefherz, +Enrico B., Ferhad Necef, Florian Bruhin, Floris Bruynooghe, Friedel Ziegelmayer, +Heimen Stoffels, Hocuri, Holger Krekel, Jikstra, Lin Miaoski, Moo, Nico de Haen, +Ole Carlsen, Osoitz, Ozancan Karataş, Pablo, Paula Petersen, Pedro Portela, +polo lancien, Racer1, Simon Laux, solokot, Waldemar Stoczkowski, Xosé M. Lamas, +Zkdc + + +## v1.1.2 +2020-01-26 + +* fix draft saving +* fix oauth2 issue introduced in 1.1.0 +* several other fixes +* update translations, update local help + + +## v1.1.1 +2020-01-24 + +* fix draft saving + + +## v1.1.0 +2020-01-21 + +* integrate the help to the app + so that it is also available when the device is offline +* rework qr-code scanning: there is now one activity with two tabs +* reduce traffic by combining read receipts and some other tweaks +* improve background-fetch on Android 9 +* fix deleting messages from server +* fix saving drafts +* other fixes +* add Korean, Serbian, Tamil, Telugu and Bokmål translations, + update other translations + +The changes have been done by Alexander Krotov, Allan Nordhøy, Angelo Fuchs, +Andrei Guliaikin, Asiel Díaz Benítez, Besnik, Björn Petersen, ButterflyOfFire, +Calbasi, cyBerta, Dmitry Bogatov, dorheim, Emil Lefherz, Enrico B., +Ferhad Necef, Florian Bruhin, Floris Bruynooghe, Friedel Ziegelmayer, +Heimen Stoffels, Hocuri, Holger Krekel, Jikstra, Lin Miaoski, Moo, Nico de Haen, +Ole Carlsen, Osoitz, Ozancan Karataş, Pablo, Pedro Portela, polo lancien, +Racer1, Simon Laux, solokot, Waldemar Stoczkowski, Xosé M. Lamas, Zkdc + + +## v1.0.3 +2019-12-22 + +* do not try to recode videos attached as files +* check write-permissions before trying to save a log +* enable some linker optimizations and make the apk about 11 mb smaller +* fix issues with some email providers +* reset device-chat on import, this removes useless or unfunctional messages + and allows messages being added again + + +## v1.0.2 +2019-12-20 + +* fix opening attachments on newer android versions +* fix accidentally shown device-chat-system-notifications on older androids +* fix sending images and other attachments for some providers +* don't recreate and thus break group membership if an unknown + sender (or mailer-daemon) sends a message referencing the group chat +* fix yandex/oauth + + +## v1.0.1 +2019-12-19 + +* fix OAauth2/GMail +* fix group members not appearing in contact list +* fix hangs appearing under some circumstances +* retry sending already after 30 seconds +* improve html parsing + + +## v1.0.0 +2019-12-17 + +Finally, after months of coding and fixing bugs, here it is: Delta Chat 1.0 :) +An overview over the changes since v0.510: + +* support for user avatars: select your profile image + at "My profile info" and it will be sent out to people you write to +* introduce a new "Device Chat" that informs the user about app changes + and, in the future, problems on the device +* new "Saved messages" chat +* add "Certificate checks" options to "Login / Advanced" +* if "Show classic emails" is set to "All", + emails pop up as contact requests directly in the chatlist +* add "Send copy to self" switch +* rework welcome screen +* a new core: for better stability, speed and future maintainability, + the core is written completely in the Rust programming language now +* for end-to-end-encryption, rPGP is used now; + the rPGP library got a first independent security review mid 2019 +* improved behavior of sending and receiving messages in flaky networks +* more reliable background fetch on newer Android versions +* native 64bit support +* minimum requirement is Android 4.3 Jelly Bean +* tons of bug fixes + +The changes of this version and the last beta versions have been done by +Alexander Krotov, Allan Nordhøy, Ampli-fier, Andrei Guliaikin, +Asiel Díaz Benítez, Besnik, Björn Petersen, ButterflyOfFire, Calbasi, cyBerta, +Daniel Boehrsi, Dmitry Bogatov, dorheim, Emil Lefherz, Enrico B., Ferhad Necef, +Florian Bruhin, Floris Bruynooghe, Friedel Ziegelmayer, Heimen Stoffels, Hocuri, +Holger Krekel, Jikstra, Lars-Magnus Skog, Lin Miaoski, Moo, Nico de Haen, +Ole Carlsen, Osoitz, Ozancan Karataş, Pablo, Pedro Portela, polo lancien, +Racer1, Simon Laux, solokot, Waldemar Stoczkowski, Xosé M. Lamas, Zkdc + + +## v0.982.0 +2019-12-16 + +* move doze-reminder to device-chat +* improve logging +* update translations +* fix crashes on connecting to some imap and smtp servers + + +## v0.981.0 +2019-12-15 + +* avatar recoding to 192x192 to keep file sizes small +* fix read-receipts appearing as normal messages +* fix smtp crash +* fix group name handling if the name contains special characters +* various other bug fixes + + +## v0.980.0 +2019-12-14 + +* support for user avatars: select your profile image + at "settings / my profile info" + and it will be sent out to people you write to +* previously selected avatars will not be used automatically, + you have to select a new avatar +* rework tls stack +* alleviate login problems with providers which only support RSA10 +* prototype a provider-database with a testprovider +* improve key gossiping +* bug fixes + + +## v0.973.0 +2019-12-10 + +* names show up correctly again +* html-attachments are possible again +* improve adding/removing group members +* improve connection handling and reconnects +* update translations + + +## v0.971.0 +2019-12-06 + +* rework welcome screen +* update translations +* improve reconnecting +* various bug fixes + + +## v0.970.0 +2019-12-04 + +* introduce a new "Device Chat" that informs the user about app changes + and, in the future, problems on the device +* rename the "Me"-chat to "Saved messages", + add a fresh icon and make it visible by default. +* add Arabic translation +* add Galician translation +* update translations +* use the rust-language for the mail-parsing and -generating part, + introducing a vastly improved reliability +* fix moving messages +* fix flakiness when receiving messages + and in the secure-join process +* more bug fixes + + +## v0.960.0 +2019-11-24 + +* update translations +* more reliable background fetch on newer Android versions +* bug fixes +* minimum requirement is now Android 4.3 Jelly Bean + + +## v0.950.0 +2019-11-05 + +* add "Certificate checks" options to "Login / Advanced" +* update translations +* bug fixes + + +## v0.940.2 +2019-10-31 + +* re-implement "delete mails from server" +* if "Show classic emails" is set to "All", + emails pop up as contact requests directly in the chatlist +* fix android9 voice-recording issues +* update translations +* various bug fixes + + +## v0.930.2 +2019-10-22 + +* add "send copy to self" switch +* rework android4 emoji-sending +* rework android9 background-fetch +* fix 64bit issues +* fix oauth2 issues +* target api level 28 (android9, pie) +* update translations +* various bug fixes + + +## v0.920.0 +2019-10-10 + +* improve onboarding error messages +* update translations +* various bug fixes + + +## v0.910.0 +2019-10-07 + +* after months of hard work, this release is finally + based on the new rust-core that brings improved security and speed, + solves build-problems and also makes future developments much easier. + there is much more to tell on that than fitting reasonably in a changelog :) +* this is also the first release including native code for 64bit systems +* minor ui improvements +* add Hungarian translation +* update translations + + +## v0.510.1 +2019-07-09 + +* new image cropping feature: crop images before sending them +* updated image editing user interface +* update Chinese (zh-cn and zh-tw), Italian, Dutch, Turkish translations +* remove swipe to archive and swipe to unarchive chats +* improve UX to discard contact requests +* improve UX to block contacts +* bugfixes + +The changes have been done by Björn Petersen, cyBerta, Enrico B., +Heimen Stoffels, Lin Miaoski, Ozancan Karataş, Zkdc + + +## v0.500.0 +2019-06-27 + +* New chat-profile: Gallery, documents, shared chats and members at a glance +* Add video recording and recoding +* Show video thumbnails +* Forward/Share: Add searching and forward/share to new contact/chat +* Share: Support direct sharing to a recently used chats +* New notification handling, including a mute-forever option :) +* Optional plipp-plop sounds in chats +* Better document- and music-files view +* Add new-messages marker +* Keep chat-scroll-position on incoming messages +* Clean up settings dialog +* More general "outgoing media quality" option (replaces image-quality option) +* Improve quality of voice messages +* More touch-friendly layout +* Add an experimental option to delete e-mails from server +* Improve compatibility with older phones +* Show a warning if the app is too old and won't be updated automatically + (done just by date comparison, no data is sent anywhere) +* New option to save the log to a file +* Make input text field a bit larger +* Add Traditional Chinese and Simplified Chinese translations +* Update Albanian, Azerbaijani, Basque, Brazilian Portuguese, Catalan, Danish, + Dutch, French, German, Italian, Japanese, Lithuanian, Polish, Portuguese, + Russian, Spanish, Swedish, Turkish and Ukrainian translations +* Bugfixes + +The changes have been done by Allan Nordhøy, Ampli-fier, Andrei Guliaikin, +Anna Ayala, Asiel Díaz Benítez, Besnik, Björn Petersen, Boehrsi, Calbasi, +Christian Schneider, cyBerta, Enrico B., Eric Lavarde, Ferhad Necef, +Floris Bruynooghe, Friedel Ziegelmayer, Heimen Stoffels, Holger Krekel, +Iskatel Istiny, Jikstra, Lars-Magnus Skog, Lin Miaoski, Luis, Moo, Ole Carlsen, +Osoitz, Ozancan Karataş, Racer, Sebek, Yuriy, Zkdc + + +## v0.304.0 +2019-05-07 + +* Add Catalan translation +* Update several other translations +* Bugfixes + +The changes have been done by Ampli-fier, Andrei Guliaikin, Asiel Díaz Benítez, +Björn Petersen, Calbasi, Enrico B., ferhad.necef, Heimen Stoffels, link2xt, +Maverick2k, Ole Carlsen, Osoitz, Ozancan Karataş, Racer1, Webratte + + +## v0.303.0 +2019-05-01 + +* Add labels to map markers +* Always show self-position on map +* Tweak Log UI +* Bugfixes + +The changes have been done by Ampli-fier, Björn Petersen, cyBerta + + +## v0.302.1 + +2019-04-27 + +* add POIs on maps +* Tweak Log UI +* add location indicator in chat messages +* bugfixes + +The changes have been done by Björn Petersen, cyBerta, Daniel Boehrsi. + + +## v0.301.1 +2019-04-22 + +* Fix chat view and log for Android 4.4 (Kitkat) + + +## v0.301.0 +2019-04-20 + +* Experimental location-streaming can be enabled in the advanced settings; + when enabled, you can optionally stream your location to a group + and view a map with the members that are also streaming their location +* Tweaked dark-mode +* Improved account setup and profile dialogs +* Show and hide the virtual keyboard more gracefully +* Speed up program start +* Speed up message sending +* Handle Webp-Images and Vcard-files +* Add Japanese and Brazilian Portuguese translations +* Update several other translations +* Bug fixes + +The changes have been done by Alexander, Ampli-fier, Angelo Fuchs, +Asiel Díaz Benítez, Besnik, Björn Petersen, cyBerta, Daniel Böhrs, Enrico B., +ferhad.necef, Floris Bruynooghe, Friedel Ziegelmayer, Heimen Stoffels, +Holger Krekel, Janka, Jikstra, Luis, Moo, Nico de Haen, Ole Carlsen, Osoitz, +Ozancan Karataş, Racer1, sebek, Viktor Pracht, Webratte and others + + +## v0.200.0 +2019-03-14 + +* Simplified setup (OAuth2) for google.com and yandex.com +* Improved setup for many other providers +* Decide, which e-mails should appear - "Chats only", "Accepted contacts" or "All" +* Improve moving chat messages to the DeltaChat folder +* Option for a stronger image compression +* Smaller message sizes in groups +* Share files from other apps to Delta Chat +* Share texts from mailto:-links +* Log can be opened from setup screen +* Add Lithuanian translation +* Update several other translations +* Bug fixes + +The changes have been done by Alexandex, Angelo Fuchs, Asiel Díaz Benítez, +Björn Petersen, Besnik, Christian Klump, cyBerta, Daniel Böhrs, Enrico B., +ferhad.necef, Florian Haar, Floris Bruynooghe, Friedel Ziegelmayer, +Heimen Stoffels, Holger Krekel, Iskatel Istiny, Lech Rowerski, Moo, +Ole Carlsen, violoncelloCH and others + + +## v0.101.0 +2019-02-12 + +* First Play Store release, optimisations for Android O +* Ask to disable battery optimisations +* Start Azerbaijani and Swedish translations +* Update several other translations +* Many bug fixes + +The changes have been done by Ampli-fier, Angelo Fuchs, Asiel Díaz Benítez, +Besnik, Björn Petersen, Christian Klump, Daniel Böhrs, Enrico B., ferhad.necef, +Florian Haar, Floris Bruynooghe, Heimen Stoffels, Holger Krekel, +Iskatel Istiny, Lech Rowerski, violoncelloCH and others. + + +## v0.100.0 +2019-01-23 + +* Complete rework of the ui using pure material design +* Images and other files can be sent together with a description +* Images can be modified before sending, eg. text can be added or + hand-drawn signs +* Image and media gallery for each chat +* Embedded camera, new camera icon directly in input field +* Embedded video player +* New emoticons +* Contacts and groups can be joined with a QR-code-scan +* Options for watching several IMAP-folders +* Option to move messages to the DeltaChat-folder +* Improved multi-device behavior +* Improved Accessibility eg. for screen readers +* Dark theme +* Support right-to-left languages +* Relative time display +* Chatlist and contact list support a long click for several operations +* Archive chats by swiping a chat right out of the chatlist +* Show date always atop of the chat +* Fix redraw problems with hidden system status or navigation bar +* Reply directly from within notification +* The system credentials have to be entered before exports +* The app can be protected by the system credentials +* Hide the permanent notification more reliable +* Improved resending of messages +* Allow password starting/ending with whitespaces +* Bug fixes +* Probably more i forgot + +The changes have been done by Ampli-fier, Angelo Fuchs, Asiel Díaz Benítez, +Björn Petersen, chklump, Daniel Böhrs, Florian Haar, Hocceruser, Holger Krekel, +Lars-Magnus Skog + +Translations are still in progress and video-recording is not yet re-implemented. +Help is very welcome -:) + + +## v0.20.0 +2018-08-14 + +* Check size before sending videos, files and other attachments +* On sending problems, try over an appropriate number of times; then give up +* Detect sending problems related to the message size, + show an error and do not try over +* Show message errors in the message info +* Add user forum to website +* Update python bindings +* Seed node.js bindings and a CLI version based on this +* Prepare Android bindings update +* Update Danish, Italian and Russian translations + +The changes have been done by Andrei Guliaikin, Angelo Fuchs, Björn Petersen, +compl4xx, Boehrsi, Enrico B., Floris Bruynooghe, Holger Krekel, Janka, Jikstra, +Karissa McKelvey, Lars-Magnus Skog, Ole Carlsen + + +## v0.19.0 +2018-07-10 + +* Give advices for Google users +* Speed up by making database-locks unnecessary +* Fix drafts appearing twice +* Update Albanian, Basque, Catalan, Danish, Dutch, English, + Italian, Polish, Russian, and Turkish translations +* Update website + +The changes have been done by Allan Nordhøy, Angelo Fuchs, Besnik, +Björn Petersen, Calbasi, Claudio Arseni, guland2000, Heimen Stoffels, +Holger Krekel, Luis Fernando Stürmer da Rosa, Mahmut Özcan, Ole Carlsen, +Osoitz, sebek, Thomas Oster + + +## v0.18.2 +2018-06-23 + +* Fix initial configure process to hang at 95% under some circumstances + + +## v0.18.0 +2018-06-21 + +* Speed up message sending/receiving +* Retry failed sending/receiving jobs just in the moment + the networks becomes available again +* Make message sending/receiving more reliable +* Handle attachment file names with non-ASCII characters correctly +* Paging through images made available by Angelo Fuchs +* Several connection issues with different configurations + were fixed by Thomas Oster +* Improve chat-folder creation by Thomas Oster +* Request permissions before using the camera; added by Thomas Oster +* Key import improved by Thomas Oster +* Improve background and foreground message fetching reliability +* Try to use the permanent notification only when really needed +* Update internal sqlite library from 3.22.0 to 3.23.1 +* Update internal libEtPan library from 1.7 to 1.8 +* Add Danish translation from Ole Carlsen +* Update Albanian, Basque, Danish, Italian, Norwegian, Dutch, Polish, + Portuguese, Russian and Telugu translations + + +## v0.17.3 +2018-05-17 + +* Fix system messages appearing twice +* Fix: Use all gossipped verifications in verified groups +* Update Basque, Polish, Russian and Ukrainian translations + + +## v0.17.2 +2018-05-15 + +* Fix problem with adding formerly uncontacted members to groups +* Unblock manually blocked members when they are added manually as contact again + + +## v0.17.1 +2018-05-11 + +* Improve QR code scanning screens +* Add a labs-option to disabled the new QR logo overlay +* Update Russian translations + + +## v0.17.0 +2018-05-07 + +* Show shared chats in user profiles +* If a contact has changed his encryption setups, + this is shown as a system messages in the middle of the chat view +* Show added group members, changed group titles etc. as system messages +* Show direct buttons to create a new group or contact in the "New Chat" dialog +* Improve "Add contact" dialog +* Move subject and most chat metadata to the encrypted part + following the "Memoryhole" proposal +* Show read-timestamps in message info +* Do not add contacts from Bcc to group-memberlist + to avoid privacy leaks and to get a unique memberlist for all group-members +* In a mail contains plaintext and encrypted parts, + the whole mail is treated as not being encrypted correctly +* Restructure settings and advanced settings +* Fix problems with Office 365 and similar services +* Fix a problem where incoming messages are shown as being sent by oneself +* Experimental QR code scanning options can be enabled in the advanced settings +* Update Albanian, Catalan, Dutch, French, German, Italian, Norwegian, Polish, + Russian, Spanish, Turkish and Ukrainian translations +* Add Basque translation +* Add Chinese translation +* Add Japanese translation + + +## v0.16.0 +2018-03-19 + +* Messages from normal clients to more than one recipient + create an implicit "ad-hoc group" +* Allow group creation though contact requests +* Always display the _sending_ time in the chat list; + the list itself is sorted by _receiving_ time + and "Message info" shows both times now +* If parts but the footnote are cut from mails, + this is indicated "..."; use "Message info" to get the full text +* Highlight the subject in the "Message info" +* Autoconfigure prefers 'https' over 'http' +* Bug fixes, eg. avoid freezes if the connection is lost +* Update Russian, Tamil and Turkish translations + + +## v0.15.0 +2018-02-27 + +* Render the waveform for voice messages +* Fix problems with voice messages on various devices +* Improve deletion of message that were moved around by another e-mail client +* Really delete messages on the server, do not only mark them for deletion +* Ignore subsequent keys or blocks in OpenPGP files +* Leave incoming Autocrypt Setup Messages in the inbox + so that any number of other e-mail-clients can process them +* Avoid messages sent to the "Me" chat appearing twice in other e-mail clients +* Update Albanian translation + + +## v0.14.0 +2018-02-20 + +* Evaluate gossiped keys +* Option to transfer the Autocrypt Setup to another device or e-mail client +* Accept Autocrypt Setup transferred from other devices or e-mail client +* Send any data from device to device + using the chat "Me - Messages I sent to myself" +* Do not send messages when there is an access error +* Request for contact permissions only once +* Bug fixes +* Update French and Turkish translations + + +## v0.13.0 +2018-01-18 + +* Reply encrypted if the sender has enabled encryption manually + (esp. useful when chatting with clients as K-9 or Enigmail) +* Update welcome screen graphics +* Update Norwegian, Russian and Turkish translations + + +## v0.12.0 +2018-01-07 + +* Gossip keys of other group members in the encrypted payload + (will also be evaluated in one of the next versions) +* Use SHA-256 instead of SHA-1 in signatures +* Make the permanent notification clickable +* Update permanent notification after import +* Fix rendering of system messages +* Various bug fixes +* Update Albanian, French, Italian, Norwegian, Polish, Russian + and Turkish translations + + +## v0.11.4 +2017-12-17 + +* Add option to initiate Autocrypt Key Transfer +* Connect after importing a backup +* Reading memory hole headers +* Add Albanian translation +* Update German, Italian, Polish, Portuguese, Russian, Turkish + and Ukrainian translations + + +## v0.10.0 +2017-11-29 + +* Fix usage of multiple private keys +* Fix various memory leaks +* Update English, Portuguese and Turkish translations + + +## v0.9.9 +2017-11-18 + +* Alternate include order for F-Droid +* Add Serbian translation +* Update Catalan, Dutch, English, French, German, Hungarian, Italian, Polish, + Portuguese, Russian, Spanish, Tamil, Telugu and Ukrainian translations + + +## v0.9.8 +2017-11-15 + +* Fix a bug that avoids chat creation under some circumstances + (bug introduced in 0.9.7) + + +## v0.9.7 +2017-11-14 + +* Archive chats or delete chats by a long press +* Notify the user in the chatlist about contact requests + of known users or of other Delta Chat clients +* Show messages only for explicitly wanted chats +* Show more detailed reasons about failed end-to-end-encryption +* Explicit option to leave a group +* Do not show the padlock if end-to-end-encryption is disabled by the user +* Import images from a backup when using a different device with different paths +* Add copy-to-clipboard function for "About / Info" +* Rework Emoji-code +* Add Norwegian Bokmål translation +* Add Tamil translation +* Add Turkish translation +* Update Catalan, German, French, Italian, Korean, Dutch, Polish, Portuguese, + Russian, Telugu and Ukrainian translations + + +## v0.9.6 +2017-10-18 + +* Support keys generated with multiple subkeys eg. from K-9 +* Show PDFs and other attachments with bad names +* Bug fixes + + +## v0.9.5 +2017-10-08 + +* Backup export and import function +* Query password before export +* Move replies from normal E-Mail-Clients to the "Chats" folder +* Improve helping MUAs on showing chat threads +* Improve onboarding +* Add URL to default footer +* Test a different approach for battery saving in this release +* Update French, Italian, German, Polish, Portuguese, Russian + and Ukrainian translations + + +## v0.9.4 +2017-08-23 + +* Introduce an editable "Status" field that is shown eg. in email footers +* Editable and synchronized group images +* Show the subject of messages that cannot be decrypted +* Do not send "Read receipts" when decryption fails +* Do not request "Read receipts" from normal MUAs + as there are too many MUAs responding with weird, non-standard formats +* Deleting a chat always deletes all messages from the device permanently +* Ignore messages from mailing lists +* Do not spread the original authors name nor address on forwarding +* Encrypt mails send to SMTP and to IMAP the same way +* Improve showing HTML-mails +* Cleanup Android code +* Remove badge counter on app restart +* Add Ukrainian translation +* Add Telugu translation +* Add Catalan translation +* Update German, Spanish, French, Hungarian, Italian, Polish, Portuguese + and Russian translations + + +## v0.9.3 +2017-07-13 + +* Introduce "Read receipts" and avoid social pressure to leave it activated +* Improve encryption dialog in profile +* Fix marking messages as "seen" when opening the contact requests +* Ignore signature.asc files of signed-only messages +* Update Polish, Portuguese and Russian translations + + +## v0.9.2 +2017-06-28 + +* Encrypt group chats +* Cryptographically sign messages +* Validate signatures of incoming messages ("Info" shows the state) +* Show lock beside end-to-end-encrypted messages with a validated signature +* If end-to-end-encryption is available on sending time, + guarantee the message not to be sent without end-to-end-encryption later +* Show special characters in HTML-mails +* Help MUAs on showing chat threads +* Show attachments from multipart/alternative structures +* Upgrade from Autocrypt Level 0 to Level 1; + as the levels are not compatible, encryption on mixed setups does not happen +* Update Polish, Portuguese, Spanish and French translations + + +## v0.9.1 +2017-06-04 + +* Profile: Improve encryption state dialog +* Improved video quality of short clips +* Make encryption-dialog localizable +* Update Russian translation + + +## v0.9.0 +2017-06-01 + +* Add end-to-end-encrypting following the OpenPGP and Autocrypt standards +* Add a function to compare keys +* Profile: Add option to copy the email address to the clipboard +* Pimp GUI + + +## v0.1.36 +2017-05-04 + +* Support camera on Android Nougat + + +## v0.1.34 +2017-05-03 + +* Link to new homepage https://delta.chat +* Localizable Help-URLs + + +## v0.1.33 +2017-04-29 + +* Better support for right-to-left (RTL) languages, taking advantage of + Android 4.2 (Jelly Bean MR1, API level 17). +* Send PNG files without resizing and converting to JPEG +* If JPEG files are send without compression, + they still appear as image, not as attached files +* Raise-to-speak defaults to false +* Unify long click behaviour +* Support Android's system function "Delete data" +* Replies to messages pop up automatically + even if send from other email addresses (typical scenario for alias addresses) +* Fix group-replies from normal email-clients. + + +## v0.1.32 +2017-04-22 + +* Update Spanish and Portuguese translations +* Update internal sqlite library to version 3.18.0, released on 2017-03-28 +* Remove more of the custom language handling, use Android's routines instead +* General code cleanup +* Play GIF files +* Option to disable autoplaying GIF files +* When sending contacts, only use the names the receivers have set themselves +* Show some hints when long-pressing icons in the action bar + + +## v0.1.29 +2017-04-19 + +* Add Russian translation +* For outgoing (group-)messages, + only use the names the receivers have set themselves + + +## v0.1.28 +2017-04-14 + +* Pimp notifications +* Bug fixes + + +## v0.1.27 +2017-04-12 + +* Use a permanent foreground service for reliable notifications +* Monitor the IMAP-IDLE thread and reconnect if IMAP-IDLE seems to hang +* Various battery and background optimizations + + +## v0.1.25 +2017-04-04 + +* Use system or user selected video player. +* Do not connect if not configured (avoids a warning on the first time startup) +* Add vertical scrollbar, eg. to settings activities. +* Pimp GUI and logo. +* Update Korean. + + +## v0.1.24 +2017-03-31 + +* Share images and documents from other apps to Delta Chat +* Offer to mailto:-link-support to other apps +* Ignore implausible sending time of incoming messages; + use the receive time in these rare cases +* Show errors only when Delta Chat is in foreground +* Dynamically adapt video bitrate for longer videos + to an attachment-size of max. 25 MB + + +## v0.1.23 +2017-03-28 + +* Retry connecting to IMAP if there is not network available on the first try +* Notify about new messages if the app is not active for hours, + optimize battery consumption + + +## v0.1.22 +2017-03-22 + +* Show HTML-only messages +* Show connection errors +* Add options for SSL/TLS and STARTTLS +* Automatic account configuration, if possible +* Recode large videos +* Add Hungarian translation +* Add Korean translation + + +## v0.1.21 +2017-03-10 + +* Record and send voice messages +* Record and send videos +* Send and play music +* Send contacts and email addresses +* Sending and opening attachments of any type +* Share and open commands for all attachments +* Accept VCards send to us by other apps +* Clickable email addresses +* Update Polish translation +* Fix tablet startup bug +* Close the app when using the lock-app-via-pincode function +* Protect data by using a content provider for sharing +* Try to clear the task switcher's screenshots when locking the app via pincode +* Pimp GUI + + +## v0.1.20 +2017-02-16 + +* Avoid unwanted downloads of lots of old messages +* Make the "Chats" folder visible if the server hides new folders by default +* Fix a crash when the server returns empty folders +* Update Polish and Portuguese translations +* Use API level 25 (Nougat 7.1) as target + + +## v0.1.18 +2017-02-11 + +* Add Polish translation +* Use a new default background for chats +* Improve typography by using the system font instead of a custom resource font +* Remove custom plural handling, use Android's routines instead +* Remove unused source code and strings +* More fixes of lint errors and warnings + + +## v0.1.17 +2017-02-07 + +* Drop two unnecessary permissions + ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION +* Really add French translation +* Update Portuguese translation +* Start fixing translation handling of the program +* Remove special "foss" build, because the whole program is free now. + + +## v0.1.16 +2017-02-06 + +* Add French translation +* Fix some lint errors and warnings + + +## v0.1.15 +2017-01-31 + +* Prepare for first release on F-Droid diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..49414fe1bd4d66fcf8f93eb770a8b5f0942333e3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,153 @@ +# Contributing Guidelines + +Thank you for looking for ways to help on Delta Chat Android! + +This document tries to outline some conventions that may not be obvious +and aims to give a good starting point to new contributors. + + +## Reporting Bugs + +If you found a bug, [report it on Github](https://github.com/deltachat/deltachat-android/issues). + +Project maintainers may transfer bugs that are not UI specific +(eg. network, database or encryption related) +to [Delta Chat Core](https://github.com/deltachat/deltachat-core-rust/issues). +If you assume beforehand, that the bug you've found belongs to Core, +you can report there directly. + +Please search both open and closed issues to make sure your bug report is not a duplicate. + +For community interactions around Delta Chat +please read our [Community Standards](https://delta.chat/community-standards). + + +## Proposing Features + +If you have a feature request, +create a new topic on the [Forum](https://support.delta.chat/c/features/6). + + +## Rough UX Philosophy + +Some rough ideas, that may be helpful when thinking about how to enhance things: + +- Work hard to avoid options and up-front choices. + Thinking about concrete user stories may help on that. +- Avoid to speak about keys and other hard to understand things in the primary UI. +- The app shall work offline as well as with bad network. +- Users do not read (much). +- Consistency matters. +- Offer only things that are highly useful to many people in primary UI. + If really needed, bury other things eg. in some menus. +- The app should be for the many, not for the few. + + +## Contributing Code + +The [BUILDING.md](./BUILDING.md) file explains in detail how to set up the build environment. +Please follow all steps precisely. +If you run into troubles, +ask on one of the [communication channels](https://delta.chat/contribute) for help. + +To contribute code, +[open a Pull Request](https://github.com/deltachat/deltachat-android/pulls). + +If you have write access to the repository, +push a branch named `/` +so it is clear who is responsible for the branch, +and open a PR proposing to merge the change. +Otherwise fork the repository and create a branch in your fork. + +Please add a meaningful description to your PR +so that reviewers get an idea about what the modifications are supposed to do. + +A meaningful PR title is helpful for [updating `CHANGELOG.md` on releases](./RELEASE.md) +(CHANGELOG.md is updated manually +to only add things that are at least roughly understandable by the end user) + +If the changes affect the user interface, +screenshots are very helpful, +esp. before/after screenshots. + + +### Coding Conventions + +Source files are partly derived from different other open source projects +and may follow different coding styles and conventions. + +If you do a PR fixing a bug or adding a feature, +please embrace the coding convention you see in the corresponding files, +so that the result fits well together. + +Do not refactor or rename things in the same PR +to make the diff small and the PR easy to review. + +Project language is Java. + +By using [Delta Chat Core](https://github.com/deltachat/deltachat-core-rust) +there is already a strong separation between "UI" and "Model". +Further separations and abstraction layers are often not helpful +and only add more complexity. + +Try to avoid premature optimisation +and complexity because it "may be needed in some future". +Usually, it is not. + +Readable code is better than having some Java paradigms fulfilled. +Classic Java has a strong drive to add lots of classes, factories, one-liner-functions. +Try to not follow these patterns and keep things really on point and simple. +If this gets in conflict with embracing existing style, however, +consistency with existing code is more important. + +The "Delta Chat Core" is a high-level interface to what the UI actually needs, +data should be served in a form that the UI do not need much additional work. +If this is not the case, consider a feature proposal to "Delta Chat Core". + + +### Merging Conventions + +PR are merged usually to the branch `main` from which [releases](./RELEASE.md) are done. + +As a default, do a `git rebase main` in case feature branches and `main` differ too much. + +Once a PR has an approval, unless stated otherwise, it can be merged by the author. +A PR may be approved but postponed to be merged eg. because of an ongoing release. + +To ensure the correct merge merge strategy, merging left up to the PR author: + +- Usually, PR are squash-merged + as UI development often results in tiny tweak commits that are not that meaningful on their own. +- If all commits are meaningful and have a well-written description, + they can be rebased-merged. + +If you do not have write access to the repository, +you may leave a note in the PR about the desired merge strategy. + + +## Translations + +Translations are done via [Transifex](https://explore.transifex.com/delta-chat/), +you can log in there with your E-Mail Address or with a Github or Google handle. +You find two projects there: +- "Delta Chat App" contains the strings used in the app's UI +- "Delta Chat Website" contains the offline help from "Settings / Help" + as well as the pages used on + +Most strings and the whole help are used for all systems +(Android, iOS, Linux, Windows, macOS) +and should be formulated accordingly. + +If you want to change the english sources, +do a PR to [`strings.xml`](https://github.com/deltachat/deltachat-android/blob/main/res/values/strings.xml) +or to [`help.md`](https://github.com/deltachat/deltachat-pages/blob/master/en/help.md). +Again, please do not mix adding things and refactorings, esp. for `help.md`, +this would require retranslations and should be considered carefully. + + +## Other Ways To Contribute + +For other ways to contribute, refer to the [website](https://delta.chat/contribute). + +If you think, something important is missed in this overview, +please do a PR to this document :) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..568542bba9cea08eab1ae20b22f1cdca547ea488 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM docker.io/debian:12.4 + +# Install Android Studio requirements +# See https://developer.android.com/studio/install#linux +RUN apt-get update -y \ +&& apt-get install -y --no-install-recommends \ +wget \ +curl \ +unzip \ +openjdk-17-jre \ +file \ +build-essential \ +&& rm -rf /var/lib/apt/lists/* + +ARG USER=deltachat +ARG UID=1000 +ARG GID=1000 + +RUN groupadd -g $GID -o $USER +RUN useradd -m -u $UID -g $GID -o $USER +USER $USER + +ENV ANDROID_SDK_ROOT /home/${USER}/android-sdk +RUN mkdir ${ANDROID_SDK_ROOT} +WORKDIR $ANDROID_SDK_ROOT +RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip && \ + unzip commandlinetools-linux-8512546_latest.zip && \ + rm commandlinetools-linux-8512546_latest.zip + +RUN yes | ${ANDROID_SDK_ROOT}/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses + +ENV PATH ${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/bin + +# Install NDK manually. Other SDK parts are installed automatically by gradle. +# +# If you change the NDK version here, also change it in `flake.nix`. +# NDK version r27 LTS aka 27.0.11902837 +RUN sdkmanager --sdk_root=${ANDROID_SDK_ROOT} 'ndk;27.0.11902837' + +ENV ANDROID_NDK_ROOT ${ANDROID_SDK_ROOT}/ndk/27.0.11902837 +ENV PATH ${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/ + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none +ENV PATH ${PATH}:/home/${USER}/.cargo/bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..94a045322262546cfb9d72561e1d587b5c2ffb1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..39b5c5f1a40bfab1991be80ae4c91118d552aacf --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +## ArcaneChat Android Client + +A [Delta Chat](https://delta.chat/) client for Android. Learn more at: https://arcanechat.me + +[Get it on Google Play](https://play.google.com/store/apps/details?id=com.github.arcanechat) +[Get it on F-Droid](https://f-droid.org/packages/chat.delta.lite) +[Get it on GitHub](https://github.com/ArcaneChat/android/releases/latest/download/ArcaneChat-gplay.apk) + + +Screenshot Screenshot + +# WebXDC + +This app has some extended support for WebXDC apps: + +- `window.webxdc.arcanechat` a string with the ArcaneChat version and can be used by app developers + to detect when they can use the ArcaneChat-specific features. +- `sendToChat()`: extra property `subject` can be set to a text string to set message/email's subject. +- `sendToChat()`: extra property `html` can be set to a string of html markup to set the HTML part of the email/message. +- `sendToChat()`: the file object parameter also accepts a `type` field that can be one of: + * `"sticker"` + * `"image"` + * `"audio"` + * `"video"` + * `"file"` (default if `type` field is not present) +- Inside apps, clicking external links is supported, ex. to open in browser, so you can include links to your website or donation pages. +- `manifest.toml` field: `orientation`, if you set it to `"landscape"` your app will be launched in landscape mode. + +# Credits + +This app is based on the [official Delta Chat client](https://github.com/deltachat/deltachat-android) with several improvements. + +This app uses a [modified](https://github.com/ArcaneChat/core) version of the [Chatmail Core Library](https://github.com/chatmail/core). diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000000000000000000000000000000000..32410514989603ca072e285549e415227c51826e --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,148 @@ +# Android Release Checklist + + +## Generate APKs + +### Update core and translations + +on the command-line, in a PR called "update-core-and-stuff-DATE": + +1. update core: + ``` + ./scripts/update-core.sh # shows used branch + ./scripts/update-core.sh BRANCH_OR_TAG # update to tag or latest commit of branch + ./scripts/clean-core.sh # helps on weird issues, do also "Build / Clean" + ./scripts/ndk-make.sh + ``` + +a) Update `CHANGELOG.md` + from , + do not just copy and avoid technical terms. + The changelog is for the end user and shall show impacts form that angle. + Add used core version to end of changelog entry + as `update to core 1.2.3` or `using core 1.2.3` + + +2. update JSON-RPC bindings: + ``` + ./scripts/update-rpc-bindings.sh + ``` + +3. update translations and local help: + ``` + ./scripts/tx-pull-translations.sh + ./scripts/create-local-help.sh # requires deltachat-pages checked out at ../deltachat-pages + ``` + +### Prepare release + +the following steps are done in a PR called `prep-VERSION` (no leading "v"): + +3. Update `CHANGELOG.md`: + Rename header with version number and add date as `YYYY-MM` + + in case previous entries of the changelog refer to betas or to not officially released versions, + the entries can be summarized. + this makes it easier for the end user to follow changes by showing major changes atop. + +4. add a device message to `ConversationListActivity::onCreate()` or remove the old one. + do not repeat the CHANGELOG here: write what really is the ux outcome + in a few lines of easy speak without technical terms. + if there is time for a translation round, do `./scripts/tx-push-source.sh` + **ping tangible translators** and start over at step 2. + +5. bump `versionCode` _and_ `versionName` (no leading "v") in `build.gradle` + +6. build APKs: + a) generate debug APK at "Build / Build Bundle(s)/APK / Build APK(s)" + b) generate release APK at "Build / Generate Signed Bundle or APK", + select "APK", add keys, flavor `gplayRelease`. + this APK will go to the stores and is located at `gplay/release` + + +## Push Test Releases + +7. a) `./scripts/upload-beta.sh VERSION` uploads both APKs to testrun.org and drafts a message. + b) add things critically to be tested to the message (this is not the changelog nor the device message) + c) post the message to relevant testing channels, **ping testers** + d) make sure, the `prep-VERSION` PR **gets merged** + +On serious deteriorations, **ping devs**, make sure they get fixed, and start over at step 1. + + +## Release on get.delta.chat + +Take care the APK used here and in the following steps +are binary-wise the same as pushed to testers and not overwritten by subsequent builds. + +8. a) `./scripts/upload-release.sh VERSION` + b) do a PR to bump `VERSION_ANDROID` (without leading `v`) on + `https://github.com/deltachat/deltachat-pages/blob/master/_includes/download-boxes.html` + c) make sure, **the PR gets merged** + and the correct APK is finally available on get.delta.chat + +only afterwards, push the APK to stores. **consider a blog post.** + + +## Release on Play Store + +on : + +9. a) open "Delta Chat / Test and release / Production" + then "Create new release" and upload APK from above + b) fill out "Release details/Release notes" (500 chars), add the line + "These features will roll out over the coming days. Thanks for using Delta Chat!"; + release name should be default ("123 (1.2.3)") + c) click "Next", set "Rollout Percentage" to 50%, click "Save" + d) Go to "Publishing Overview", "Managed publishing" is usually off; + click "Send change for review", confirm + +2 days later, change "Rollout Percentage" to 99%. Two more days later to 100%. +Rollout is anyways slower in practise, however, +only as long as we do not enter 100%, we can retract the version +(Once we reach 100%, we have to submit a new version for approval. +During these up to 4 days, sometimes longer, we cannot do anything on existing rollout) + + +## Tag for F-Droid and create Github release + +10. make sure, everything is pushed, then: + $ git tag v1.2.1 COMMIT; git push --tags + +F-Droid picks on the tags starting with "v" and builds the version. +This may take some days. + +11. a) on , + tap "Draft a new Release", choose just created tag, fill changelog + b) add APK from above using "Attach binary". + c) tap "Publish release" + + +## Release on Huawei AppGallery + +on : + +13. a) go to "Upload your app / Android / Delta Chat / Update", again "Update" upper right + b) "Manage Packages / Upload", upload the APK from above, hit "Save" + c) Update "App Information / New Features", hit "Save", then "Next" + d) Hit "Submit"; on the next page, confirm version and language + + +## Releases on other stores (ex. Passkoocheh) + +These stores are not under our control. +On important updates **ping store maintainers** and ask to update. + + +## Testing checklist + +Only some rough ideas, ideally, this should result into a simple checklist +that can be checked before releasing. +However, although it would be nice to test "everything", we should keep in mind +that the test should be doable in, say, 10~15 minutes. +- create new account with (one of?): gmail, yandex, other + or (?) test an existing account +- send and receive a message +- create a group +- do a contact verification +- join a group via a qr scan diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..9ab32913558d3ac0fb6b2c3cfc28add004221177 --- /dev/null +++ b/build.gradle @@ -0,0 +1,308 @@ +plugins { + id 'com.android.application' version '8.11.1' + id 'com.google.gms.google-services' version '4.4.1' +} + +repositories { + google() + mavenCentral() + maven { + url "https://www.jitpack.io" + name 'JitPack Github wrapper' + } +} + +android { + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + namespace "org.thoughtcrime.securesms" + flavorDimensions "none" + compileSdk 36 + + // Set NDK version to strip native libraries. + // Even though we compile our libraries outside Gradle with `scripts/ndk-make.sh`, + // without ndkVersion `./gradlew clean` followed by `./gradlew assembleDebug --warning-mode=all` emits the following warning: + // > Task :stripFatDebugDebugSymbols + // Unable to strip the following libraries, packaging them as they are: libanimation-decoder-gif.so, libnative-utils.so. + // See for details. + ndkVersion "27.0.12077973" + useLibrary 'org.apache.http.legacy' + + defaultConfig { + versionCode 30000734 + versionName "2.33.1" + + applicationId "chat.delta.lite" + multiDexEnabled true + + minSdkVersion 21 + targetSdkVersion 36 + + vectorDrawables.useSupportLibrary = true + + // base name of the generated apk + project.ext.set("archivesBaseName", "deltachat"); + + buildConfigField "boolean", "DEV_BUILD", "false" + + ndk { + if(project.hasProperty("ABI_FILTER")) { + abiFilters ABI_FILTER + } else { + abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + } + } + + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "TEST_ADDR", buildConfigProperty("TEST_ADDR")) + buildConfigField("String", "TEST_MAIL_PW", buildConfigProperty("TEST_MAIL_PW")) + buildConfigField("String", "NDK_ARCH", getNdkArch()) + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + packagingOptions { + jniLibs { + doNotStrip '**/*.so' + keepDebugSymbols += ['*/mips/*.so', '*/mips64/*.so'] + } + resources { + excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE'] + } + } + + + signingConfigs { + debug { + // add `DC_DEBUG_STORE_FILE=/path/to/debug.keystore` to `~/.gradle/gradle.properties` + if(project.hasProperty("DC_DEBUG_STORE_FILE" )) { + storeFile file(DC_DEBUG_STORE_FILE ) + } + } + releaseFdroid { + // can be defined at `~/.gradle/gradle.properties` or at "Build/Generate signed APK" + if(project.hasProperty("DC_RELEASE_STORE_FILE")) { + storeFile file(DC_RELEASE_STORE_FILE) + storePassword DC_RELEASE_STORE_PASSWORD + keyAlias DC_RELEASE_KEY_ALIAS_FDROID + keyPassword DC_RELEASE_KEY_PASSWORD + } + } + releaseApk { + // can be defined at `~/.gradle/gradle.properties` or at "Build/Generate signed APK" + if(project.hasProperty("DC_RELEASE_STORE_FILE")) { + storeFile file(DC_RELEASE_STORE_FILE) + storePassword DC_RELEASE_STORE_PASSWORD + keyAlias DC_RELEASE_KEY_ALIAS_GPLAY + keyPassword DC_RELEASE_KEY_PASSWORD + } + } + releaseBundle { + if(project.hasProperty("DC_BUNDLE_STORE_FILE")) { + storeFile file(DC_BUNDLE_STORE_FILE) + storePassword DC_BUNDLE_STORE_PASSWORD + keyAlias DC_BUNDLE_KEY_ALIAS + keyPassword DC_BUNDLE_STORE_PASSWORD + } + } + } + + productFlavors { + foss { + dimension "none" + buildConfigField "boolean", "USE_PLAY_SERVICES", "false" + } + gplay { + dimension "none" + apply plugin: "com.google.gms.google-services" + buildConfigField "boolean", "USE_PLAY_SERVICES", "true" + applicationId "com.github.arcanechat" + } + } + + buildTypes { + debug { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + testProguardFiles 'test-proguard-rules.pro' + applicationIdSuffix ".beta" + } + release { + // minification and proguard disabled for now. + // + // when enabled, it can cut down apk size about 6%, + // however this also has the potential to break things. + // so exceptions are needed and have to be maintained. + // (see git-history and https://github.com/deltachat/deltachat-android/issues/905 ) + // + // nb: it is highly recommended to use the same settings in debug+release - + // otherwise problems might be noticed delayed only + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + testProguardFiles 'test-proguard-rules.pro' + productFlavors.foss.signingConfig signingConfigs.releaseFdroid + productFlavors.gplay.signingConfig signingConfigs.releaseApk + } + } + + if(!project.hasProperty("ABI_FILTER")) { + splits { + abi { + enable true + reset() + include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + universalApk true + } + } + } + + project.ext.versionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] + + android.applicationVariants.all { variant -> + variant.outputs.all { output -> + output.outputFileName = output.outputFileName + .replace("android", "ArcaneChat") + .replace("-release", "") + .replace(".apk", "-${variant.versionName}.apk") + if(project.hasProperty("ABI_FILTER")) { + output.versionCodeOverride = + variant.versionCode * 10 + project.ext.versionCodes.get(ABI_FILTER) + } else { + output.versionCodeOverride = + variant.versionCode * 10 + project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 4) + } + } + } + + sourceSets { + main { + jniLibs.srcDirs = ['libs'] + } + } + + androidResources { + generateLocaleConfig true + } + + lint { + abortOnError false + } + buildFeatures { + renderScript true + aidl true + } + +} + +final def markwon_version = '4.6.2' + +dependencies { + // ArcaneChat-only dependencies: + implementation "io.noties.markwon:core:$markwon_version" + implementation "io.noties.markwon:ext-strikethrough:$markwon_version" + implementation "io.noties.markwon:inline-parser:$markwon_version" + implementation 'com.airbnb.android:lottie:4.2.2' // Lottie animations support. + + implementation 'androidx.concurrent:concurrent-futures:1.3.0' + implementation 'androidx.sharetarget:sharetarget:1.2.0' + implementation 'androidx.webkit:webkit:1.14.0' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.legacy:legacy-support-v13:1.0.0' + implementation ('androidx.preference:preference:1.2.1') { + exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel' + exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel-ktx' + } + implementation 'androidx.legacy:legacy-preference-v14:1.0.0' + implementation 'androidx.exifinterface:exifinterface:1.4.1' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.6.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' + implementation 'androidx.work:work-runtime:2.9.1' + implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0' + implementation 'com.google.guava:guava:31.1-android' + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // plays video and audio + implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'com.google.zxing:core:3.3.0' // fixed version to support SDK<24 + implementation ('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false } // QR Code scanner + implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.1' // used as JSON library + implementation 'com.github.Baseflow:PhotoView:2.3.0' // does the zooming on photos / media + implementation 'com.caverock:androidsvg-aar:1.4' // SVG support. + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' + annotationProcessor 'androidx.annotation:annotation:1.9.1' + implementation 'com.makeramen:roundedimageview:2.3.0' // crops the avatars to circles + implementation 'com.github.amulyakhare:TextDrawable:558677ea31' // number of unread messages, + // the one-letter circle for the contacts (when there is not avatar) and a white background. + implementation 'com.googlecode.mp4parser:isoparser:1.0.6' // MP4 recoding; upgrading eg. to 1.1.22 breaks recoding, however, i have not investigated further, just reset to 1.0.6 + implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.10.0') { // for the zooming on photos / media + exclude group: 'com.android.support', module: 'support-annotations' + } + + // Replacement for ContentResolver + // that protects against the Surreptitious Sharing attack. + // + implementation 'de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0' + + gplayImplementation('com.google.firebase:firebase-messaging:24.1.2') { // for PUSH notifications, don't upgrade: v25.0.0 requires minSdk>=23 + exclude group: 'com.google.firebase', module: 'firebase-core' + exclude group: 'com.google.firebase', module: 'firebase-analytics' + exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' + } + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.assertj:assertj-core:3.27.3' + testImplementation 'org.mockito:mockito-core:5.18.0' + testImplementation 'org.powermock:powermock-api-mockito:1.7.4' + testImplementation 'org.powermock:powermock-module-junit4:2.0.9' + testImplementation 'org.powermock:powermock-module-junit4-rule:2.0.9' + testImplementation 'org.powermock:powermock-classloading-xstream:2.0.9' + + androidTestImplementation 'androidx.test:runner:1.7.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.7.0' + androidTestImplementation 'androidx.test:rules:1.7.0' + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation 'com.android.support:support-annotations:28.0.0' + + androidTestImplementation ('org.assertj:assertj-core:3.27.3') { + exclude group: 'org.hamcrest', module: 'hamcrest-core' + } +} + +String buildConfigProperty(String name) { + return "\"${propertyOrEmpty(name)}\"" +} + +String propertyOrEmpty(String name) { + Object p = findProperty(name) + if (p == null) return environmentVariable(name) + return (String) p +} + +static String environmentVariable(String name) { + String env = System.getenv(name) + if (env == null) return "" + return env +} + +String getNdkArch() { + Properties properties = new Properties() + def file = project.rootProject.file('ndkArch') + if (!file.exists()) return "\"\"" + properties.load(file.newDataInputStream()) + def arch = properties.getProperty('NDK_ARCH') + if (arch == null) return "\"\"" + return "\"$arch\"" +} diff --git a/docs/f-droid.md b/docs/f-droid.md new file mode 100644 index 0000000000000000000000000000000000000000..332b7086c8168a9c80e655bbb09ad94820d464c4 --- /dev/null +++ b/docs/f-droid.md @@ -0,0 +1,79 @@ +# F-Droid - Overview + +- + is the Delta Chat page on F-Droid.org, + the F-Droid app will show similar information. + +- + contains the description, icon, screenshots and all meta data shown for Delta Chat on F-Droid + in the [fastlane format](https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/#fastlane-structure). + +- + contains [additional F-Droid-specific metadata](https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/#in-the-f-droid-repo) + and build instructions that do not fit the fastlane format. + F-Droid adds new versions automatically to the end of `.yml` file. + +- New versions are recognized by tags in the form `v1.2.3` - + before adding tags like that, have a look at + . + The build and distribution is expected to take + [up to 7 days](https://gitlab.com/fdroid/wiki/-/wikis/FAQ#how-long-does-it-take-for-my-app-to-show-up-on-website-and-client). + + +# F-Droid Build status + +- + shows F-Droid's overall build status, + if Delta Chat shows up at "Need updating" or "Running", + things are working as expected. :) + +- + (with VERSIONCODE = 537 or so) links to successfully built apk + even if it is not yet in the index (which may take some more time). + F-Droid keeps the last 3 successful builds in the main repo, + while the rest will be moved to the Archive repo: + + + +# Use F-Droid-tools locally + +$ git clone https://gitlab.com/fdroid/fdroiddata +$ git clone https://gitlab.com/fdroid/fdroidserver +$ cd fdroiddata + +Now, metadata/com.b44t.messenger.yml can be modified. +For testing, one can change the repo to a branch +by adding the line `Update Check Mode:RepoManifest/BRANCH` to the file. + +Set some path to ndk etc: +$ cp ../fdroidserver/examples/config.py . # adapt file as needed + +Checkout repo as F-Droid would do: +$ ../fdroidserver/fdroid checkupdates -v com.b44t.messenger +(for testing with uncommitted changes, add --allow-dirty) + +Build repo as F-Droid would do: +$ ../froidserver/fdroid build -v com.b44t.messenger: + +(via +and - +might require `pip install pyasn1 pyasn1_modules pyaml requests`) + + +# Changing the description + +- Change the files `metadata/en-US/short_description.txt` + and `metadata/en-US/full_description.txt` + in repository. + +- Make sure there is a "newline" at the end of the description + (see ). + + +# Changing F-Droid metadata + +- The file `com.b44t.messenger.yml` can be changed via a PR to the repository. + +- Reformat the metadata using + $ ../fdroidserver/fdroid rewritemeta com.b44t.messenger # called from fdroiddata dir + diff --git a/docs/images/2019-12-material-icon-dev-template.png b/docs/images/2019-12-material-icon-dev-template.png new file mode 100644 index 0000000000000000000000000000000000000000..ecb28b24b00c4a684ca1b3b887aac29af5de12ce Binary files /dev/null and b/docs/images/2019-12-material-icon-dev-template.png differ diff --git a/docs/images/2019-12-material-icon-template.png b/docs/images/2019-12-material-icon-template.png new file mode 100644 index 0000000000000000000000000000000000000000..ba4320ec202387e91b28c2a346c1245c4bed1a15 Binary files /dev/null and b/docs/images/2019-12-material-icon-template.png differ diff --git a/docs/playstore.md b/docs/playstore.md new file mode 100644 index 0000000000000000000000000000000000000000..b6b41863c86a71bcddd8ef80ac46960b5ac477df --- /dev/null +++ b/docs/playstore.md @@ -0,0 +1,14 @@ +# Google Play Store + +If you have access to uploading and signing apks, +this can be done at +https://play.google.com/apps/publish/ + +The description can be discussed and changed at +https://github.com/deltachat/deltachat-android/blob/master/store/text.md +In future, we can also add the screenshots or other assets there. + + +# Google Play Releases + +see `release-checklist.md` diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..dd4a43cbde7b928fd449658c660d667b0e52c55a --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,36 @@ +ArcaneChat is a decentralized and secure instant messenger that is easy to use for friends and family. + +• Anonymous. Instant onboarding without a phone number, e-mail or other private data. + +• Flexible. Supports multiple chat profiles and is easy to setup on multiple devices. + +• Extensible. Use mini-apps in chats like shopping lists, calendars or games. + +• Reliable. Works under bad and adversarial network conditions. + +• Secure. Audited End-to-End encryption safe against network and server attacks. + +• Sovereign. Can be run with your own e-mail address or server. + +ArcaneChat is a Delta Chat client and was created with a focus on usability, good user experience, and saving data plan. Also the app usually experiments with new features that eventually might get added to the official Delta Chat client. + +Main differences with official Delta Chat client: + +
    +
  • Support for some markdown styles in text messages (bold, italic, strike, etc.)
  • +
  • Support for displaying Telegram's animated stickers (.tgs files)
  • +
  • Support for SVG images previews
  • +
  • Multiple color themes/skins
  • +
  • It is possible to disable profiles to completely disconnect them saving data/bandwidth
  • +
  • You can easily see the connection status of all your profiles in the profile switcher
  • +
  • Extra option to share location for 12 hours
  • +
  • Clicking on a message with a POI location, will open the POI on the map
  • +
  • Last-seen status of contacts is shown in your contact list, like in WhatsApp, Telegram, etc.
  • +
  • Videos are played in loop, useful for short GIF videos
  • +
  • Verified icon is shown in the chat list for the "Device Messages" and "Saved Messages" chat to avoid fishing attempts by scammer pretending to be the official chats
  • +
  • Voice messages have more aggressive compression in "worse quality" mode to save data plan
  • +
  • Automatic download of messages limited to 640KB by default
  • +
  • Profile's display name is always shown in the app's title bar instead of the name of the app
  • +
  • For mini-apps developers: there are some extra features in the WebXDC API, check https://github.com/ArcaneChat/android/#webxdc
  • +
  • Better settings organization with additional "Privacy" section
  • +
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000000000000000000000000000000000000..02a8898a8f786bc9f0ea49a6ee4b8f468e6c76e2 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/featureGraphic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6d8c6806831f08c6f05243505c277a15601ff4b492f7bfeef0e2afd64a7ab83 +size 140597 diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9ef22ffea785afb4e2742ab2ae8478cd535c6e82 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000000000000000000000000000000000000..bea8a1ed0eb0c94db606c315cebc2a13adcfb082 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8913fce1c6983d65dca9f5b83c4894115daeb5505726f87933aa5d48e90791cf +size 294234 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000000000000000000000000000000000000..2cb01fc474460b679859a57cb96a51deeb40b324 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d05327186fd61b2907512332ac911bd26efb2466893845a9f14196b53afd59b +size 262472 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000000000000000000000000000000000000..d9d2141f117bc9e9ad216a2f9eba3a2e58137114 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:932b9e9fcd78fbde98ffea884f9654e055dca124b31dd54e830ae6caf6f02d6a +size 457523 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000000000000000000000000000000000000..097b122fc16653e2811fd7efdd507f587f303eb2 --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bae9b3ba852b22b011f687ec00100e63b2dd7cd0474f01d0494cc223c1efce0 +size 177322 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000000000000000000000000000000000000..8aea380b0b2afc7f61e771e5deb2266b0e9bef1d --- /dev/null +++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11c06858382dfd798cfff4ef9830f19815928839319def53b30106276c3286a3 +size 220330 diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..55932e8ccfbf54d5e974eebc43fb0c62f737ee47 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +⚡ Fast encrypted chats for the family 🎉 diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000000000000000000000000000000000000..f75986e808a7cdb5fc49de069558f162b1e62115 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +ArcaneChat diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..bde8ccc86f637f1c4e066d2036a64634a5c11a67 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1,36 @@ +ArcaneChat — децентрализованный и защищённый мессенджер, удобный для друзей и семьи. + +• Анонимность. Мгновенный вход без номера телефона, e-mail или других личных данных. + +• Гибкость. Поддерживает несколько профилей чатов и легко настраивается на нескольких устройствах. + +• Расширяемость. В чатах можно использовать мини-приложения: списки покупок, календари или игры. + +• Надёжность. Работает даже при плохом соединении и в сложных сетевых условиях. + +• Безопасность. Проверенное сквозное шифрование, защищённое от сетевых и серверных атак. + +• Самостоятельность. Может работать с вашим собственным e-mail-адресом или сервером. + +ArcaneChat — клиент Delta Chat, разработанный с акцентом на удобство, качественный UX и экономию трафика. Также приложение часто экспериментирует с новыми функциями, которые со временем могут быть добавлены в официальный клиент Delta Chat. + +Основные отличия от официального клиента Delta Chat: + +
    +
  • Поддержка некоторых стилей markdown в текстовых сообщениях (жирный, курсив, зачёркнутый и т.д.)
  • +
  • Поддержка отображения анимированных стикеров Telegram (.tgs-файлы)
  • +
  • Поддержка предпросмотра SVG-изображений
  • +
  • Несколько цветовых тем/скинов
  • +
  • Возможность отключать профили, полностью отключая им доступ в сеть для экономии трафика
  • +
  • На панели переключения профилей видно состояние подключения каждого профиля
  • +
  • Дополнительная опция для обмена местоположением на 12 часов
  • +
  • Нажатие на сообщение с POI открывает его на карте
  • +
  • Статус «был(а) в сети» отображается в списке контактов, как в WhatsApp, Telegram и т.д.
  • +
  • Видео воспроизводятся по кругу — удобно для коротких GIF-видео
  • +
  • У чата «Device Messages» в списке чатов отображается значок подтверждения
  • +
  • В режиме «низкое качество» голосовые сообщения сжимаются сильнее для экономии трафика
  • +
  • Автоматическая загрузка сообщений по умолчанию ограничена 640KB
  • +
  • Отображаемое имя профиля всегда видно в заголовке приложения вместо названия приложения
  • +
  • Для разработчиков мини-приложений: доступны дополнительные возможности WebXDC API, см. https://github.com/ArcaneChat/android/#webxdc
  • +
  • Более удобная организация настроек с дополнительным разделом «Privacy»
  • +
diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..0ec6e8342d82930f8443a08842c882c94dc05d47 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +⚡ Быстрые зашифрованные чаты для семьи 🎉 diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 100644 index 0000000000000000000000000000000000000000..f75986e808a7cdb5fc49de069558f162b1e62115 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +ArcaneChat diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000000000000000000000000000000000..457cd525715a2b68fa6a1d5056c98bd6e68d3a5f --- /dev/null +++ b/flake.lock @@ -0,0 +1,187 @@ +{ + "nodes": { + "android": { + "inputs": { + "devshell": "devshell", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1756239746, + "narHash": "sha256-0ibN685tT+u/Nbmbrrq9G3mRUzct2Votyv/a7Wwv26s=", + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "256631d162ec883b2341ee59621516e1f65f0f6b", + "type": "github" + }, + "original": { + "owner": "tadfisher", + "repo": "android-nixpkgs", + "type": "github" + } + }, + "devshell": { + "inputs": { + "nixpkgs": [ + "android", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1741473158, + "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", + "owner": "numtide", + "repo": "devshell", + "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756125398, + "narHash": "sha256-XexyKZpf46cMiO5Vbj+dWSAXOnr285GHsMch8FBoHbc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3b9f00d7a7bf68acd4c4abb9d43695afb04e03a5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1756159630, + "narHash": "sha256-ohMvsjtSVdT/bruXf5ClBh8ZYXRmD4krmjKrXhEvwMg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "84c256e42600cb0fdf25763b48d28df2f25a0c8b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "android": "android", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1763347184, + "narHash": "sha256-6QH8hpCYJxifvyHEYg+Da0BotUn03BwLIvYo3JAxuqQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "08895cce80433978d5bfd668efa41c5e24578cbd", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000000000000000000000000000000000..de876b7f88b404a0a3a00111c99674ec3a9e936f --- /dev/null +++ b/flake.nix @@ -0,0 +1,49 @@ +{ + description = "Delta Chat for Android"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + flake-utils.url = "github:numtide/flake-utils"; + android.url = "github:tadfisher/android-nixpkgs"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils, android }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + android-sdk = android.sdk.${system} (sdkPkgs: + with sdkPkgs; [ + build-tools-35-0-0 + cmdline-tools-latest + platform-tools + platforms-android-36 + ndk-27-2-12479018 + ]); + rust-version = pkgs.lib.removeSuffix "\n" + (builtins.readFile ./scripts/rust-toolchain); + in + { + formatter = pkgs.nixpkgs-fmt; + + devShells.default = pkgs.mkShell rec { + ANDROID_SDK_ROOT = "${android-sdk}/share/android-sdk"; + ANDROID_NDK_ROOT = + "${android-sdk}/share/android-sdk/ndk/27.2.12479018"; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/35.0.0/aapt2"; + buildInputs = [ + android-sdk + pkgs.openjdk17 + (pkgs.buildPackages.rust-bin.stable."${rust-version}".minimal.override { + targets = [ + "armv7-linux-androideabi" + "aarch64-linux-android" + "i686-linux-android" + "x86_64-linux-android" + ]; + }) + ]; + }; + }); +} diff --git a/google-services.json b/google-services.json new file mode 100644 index 0000000000000000000000000000000000000000..d11e6a460d70404c6b547a4659429fbfb7cb5bb8 --- /dev/null +++ b/google-services.json @@ -0,0 +1,105 @@ +{ + "project_info": { + "project_number": "922391085500", + "project_id": "delta-chat-fcm", + "storage_bucket": "delta-chat-fcm.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:922391085500:android:938fa7a685ce74ba3e2bb9", + "android_client_info": { + "package_name": "chat.delta" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYH8Iznh8btYX7g_udv_bu68VH30zzxho" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:922391085500:android:6f54e2c4e49405673e2bb9", + "android_client_info": { + "package_name": "com.github.arcanechat.beta" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYH8Iznh8btYX7g_udv_bu68VH30zzxho" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:922391085500:android:aff82fbc40c8172e3e2bb9", + "android_client_info": { + "package_name": "com.github.arcanechat" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYH8Iznh8btYX7g_udv_bu68VH30zzxho" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:922391085500:android:92b4cf12669cc2083e2bb9", + "android_client_info": { + "package_name": "chat.delta.lite" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYH8Iznh8btYX7g_udv_bu68VH30zzxho" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:922391085500:android:228a205b8aa2bacc3e2bb9", + "android_client_info": { + "package_name": "chat.delta.lite.beta" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYH8Iznh8btYX7g_udv_bu68VH30zzxho" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000000000000000000000000000000000..6563318eab2acfe0c78b0351d588a74a300373ef --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +android.defaults.buildfeatures.buildconfig=true +android.enableJetifier=true +android.nonTransitiveRClass=false +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4608m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..ccebba7710deaf9f98673a68957ea02138b60d0a Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000000000000000000000000000000000..57939822b8255bce640f8bee58b13df778862008 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000000000000000000000000000000000000..79a61d421cc4e272926b1d590728d0bbfc224b0d --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000000000000000000000000000000000..6689b85beecde676054c39c2408085f41e6be6dc --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jni/Android.mk b/jni/Android.mk new file mode 100644 index 0000000000000000000000000000000000000000..286ed6c3ddfa2e5a86d2d160f8c6e84bde67c9a1 --- /dev/null +++ b/jni/Android.mk @@ -0,0 +1,36 @@ +JNI_DIR := $(call my-dir) +LOCAL_PATH := $(call my-dir) + +# Include prebuilt rust + +include $(CLEAR_VARS) +LOCAL_MODULE := deltachat-core +LOCAL_SRC_FILES := $(TARGET_ARCH_ABI)/libdeltachat.a +# The header files should be located in the following dir relative to jni/ dir +LOCAL_EXPORT_C_INCLUDES := include/ +include $(PREBUILT_STATIC_LIBRARY) + + +################################################################################ +# main shared library as used from Java (includes the static ones) +################################################################################ + +include $(CLEAR_VARS) + +LOCAL_MODULE := native-utils + +LOCAL_C_INCLUDES := $(JNI_DIR)/utils/ +LOCAL_LDLIBS := -llog +LOCAL_STATIC_LIBRARIES := deltachat-core + +# -Werror flag is important to catch incompatibilities between the JNI bindings and the core. +# Otherwise passing a variable of different type such as char * instead of int +# causes only a -Wint-conversion warning. +LOCAL_CFLAGS := -Werror -Wno-pointer-to-int-cast -Wno-int-to-pointer-cast -DNULL=0 -DSOCKLEN_T=socklen_t -DLOCALE_NOT_USED -D_LARGEFILE_SOURCE=1 -D_FILE_OFFSET_BITS=64 +LOCAL_CFLAGS += -Drestrict='' -D__EMX__ -DFIXED_POINT -DUSE_ALLOCA -DHAVE_LRINT -DHAVE_LRINTF -fno-math-errno +LOCAL_CFLAGS += -DANDROID_NDK -DDISABLE_IMPORTGL -fno-strict-aliasing -DAVOID_TABLES -DANDROID_TILE_BASED_DECODE -DANDROID_ARMV6_IDCT -ffast-math -D__STDC_CONSTANT_MACROS + +LOCAL_SRC_FILES := dc_wrapper.c +LOCAL_LDFLAGS += -Wl,--build-id=none + +include $(BUILD_SHARED_LIBRARY) diff --git a/jni/Application.mk b/jni/Application.mk new file mode 100644 index 0000000000000000000000000000000000000000..c6d1bbbd480570bcf13478857fc9a9a18ec63e0a --- /dev/null +++ b/jni/Application.mk @@ -0,0 +1,8 @@ +APP_PLATFORM := android-21 +APP_ABI := armeabi-v7a arm64-v8a x86 x86_64 +APP_STL := none + +ifneq ($(NDK_DEBUG),1) +APP_CFLAGS += -Oz -flto=full -fno-unwind-tables -fno-exceptions -fno-asynchronous-unwind-tables -fomit-frame-pointer +APP_LDFLAGS += -flto=full +endif diff --git a/jni/dc_wrapper.c b/jni/dc_wrapper.c new file mode 100644 index 0000000000000000000000000000000000000000..9a8bc72730276a14b041d57984e55804de5256d1 --- /dev/null +++ b/jni/dc_wrapper.c @@ -0,0 +1,2058 @@ +// Purpose: The C part of the Java<->C Wrapper, see also DcContext.java + + +#include +#include +#include +#include "deltachat-core-rust/deltachat-ffi/deltachat.h" + + +static dc_msg_t* get_dc_msg(JNIEnv *env, jobject obj); + + +// passing a NULL-jstring results in a NULL-ptr - this is needed by functions using eg. NULL for "delete" +#define CHAR_REF(a) \ + char* a##Ptr = char_ref__(env, (a)); +static char* char_ref__(JNIEnv* env, jstring a) { + if (a==NULL) { + return NULL; + } + + /* we do not use the JNI functions GetStringUTFChars()/ReleaseStringUTFChars() + as they do not work on some older systems for code points >0xffff, eg. emojis. + as a workaround, we're calling back to java-land's String.getBytes() which works as expected */ + static jclass s_strCls = NULL; + static jmethodID s_getBytes = NULL; + static jclass s_strEncode = NULL; + if (s_getBytes==NULL) { + s_strCls = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "java/lang/String")); + s_getBytes = (*env)->GetMethodID(env, s_strCls, "getBytes", "(Ljava/lang/String;)[B"); + s_strEncode = (*env)->NewGlobalRef(env, (*env)->NewStringUTF(env, "UTF-8")); + } + + const jbyteArray stringJbytes = (jbyteArray)(*env)->CallObjectMethod(env, a, s_getBytes, s_strEncode); + const jsize length = (*env)->GetArrayLength(env, stringJbytes); + jbyte* pBytes = (*env)->GetByteArrayElements(env, stringJbytes, NULL); + if (pBytes==NULL) { + return NULL; + } + + char* cstr = strndup((const char*)pBytes, length); + + (*env)->ReleaseByteArrayElements(env, stringJbytes, pBytes, JNI_ABORT); + (*env)->DeleteLocalRef(env, stringJbytes); + + return cstr; +} + +#define CHAR_UNREF(a) \ + free(a##Ptr); + +#define JSTRING_NEW(a) jstring_new__(env, (a)) +static jstring jstring_new__(JNIEnv* env, const char* a) +{ + if (a==NULL || a[0]==0) { + return (*env)->NewStringUTF(env, ""); + } + + /* for non-empty strings, do not use NewStringUTF() as this is buggy on some Android versions. + Instead, create the string using `new String(ByteArray, "UTF-8);` which seems to be programmed more properly. + (eg. on KitKat a simple "SMILING FACE WITH SMILING EYES" (U+1F60A, UTF-8 F0 9F 98 8A) will let the app crash, reporting 0xF0 is a bad UTF-8 start, + see http://stackoverflow.com/questions/12127817/android-ics-4-0-ndk-newstringutf-is-crashing-down-the-app ) */ + static jclass s_strCls = NULL; + static jmethodID s_strCtor = NULL; + static jclass s_strEncode = NULL; + if (s_strCtor==NULL) { + s_strCls = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "java/lang/String")); + s_strCtor = (*env)->GetMethodID(env, s_strCls, "", "([BLjava/lang/String;)V"); + s_strEncode = (*env)->NewGlobalRef(env, (*env)->NewStringUTF(env, "UTF-8")); + } + + int a_bytes = strlen(a); + jbyteArray array = (*env)->NewByteArray(env, a_bytes); + (*env)->SetByteArrayRegion(env, array, 0, a_bytes, (const jbyte*)a); + jstring ret = (jstring) (*env)->NewObject(env, s_strCls, s_strCtor, array, s_strEncode); + (*env)->DeleteLocalRef(env, array); /* we have to delete the reference as it is not returned to Java, AFAIK */ + + return ret; +} + + +// convert c-timestamp to java-timestamp +#define JTIMESTAMP(a) (((jlong)a)*((jlong)1000)) + + +// convert java-timestamp to c-timestamp +#define CTIMESTAMP(a) (((jlong)a)/((jlong)1000)) + + +static jbyteArray ptr2jbyteArray(JNIEnv *env, const void* ptr, size_t len) { + if (ptr == NULL || len <= 0) { + return NULL; + } + jbyteArray ret = (*env)->NewByteArray(env, len); + if (ret == NULL) { + return NULL; + } + (*env)->SetByteArrayRegion(env, ret, 0, len, (const jbyte*)ptr); + return ret; +} + + +static jintArray dc_array2jintArray_n_unref(JNIEnv *env, dc_array_t* ca) +{ + /* takes a C-array of type dc_array_t and converts it it a Java-Array. + then the C-array is freed and the Java-Array is returned. */ + int i, icnt = ca? dc_array_get_cnt(ca) : 0; + jintArray ret = (*env)->NewIntArray(env, icnt); if (ret==NULL) { return NULL; } + + if (ca) { + if (icnt) { + jint* temp = calloc(icnt, sizeof(jint)); + for (i = 0; i < icnt; i++) { + temp[i] = (jint)dc_array_get_id(ca, i); + } + (*env)->SetIntArrayRegion(env, ret, 0, icnt, temp); + free(temp); + } + dc_array_unref(ca); + } + + return ret; +} + + +static uint32_t* jintArray2uint32Pointer(JNIEnv* env, jintArray ja, int* ret_icnt) +{ + /* takes a Java-Array and converts it to a C-Array. */ + uint32_t* ret = NULL; + if (ret_icnt) { *ret_icnt = 0; } + + if (env && ja && ret_icnt) + { + int i, icnt = (*env)->GetArrayLength(env, ja); + if (icnt > 0) + { + jint* temp = (*env)->GetIntArrayElements(env, ja, NULL); + if (temp) + { + ret = calloc(icnt, sizeof(uint32_t)); + if (ret) + { + for (i = 0; i < icnt; i++) { + ret[i] = (uint32_t)temp[i]; + } + *ret_icnt = icnt; + } + (*env)->ReleaseIntArrayElements(env, ja, temp, 0); + } + } + } + + return ret; +} + + +/******************************************************************************* + * DcAccounts + ******************************************************************************/ + + +static dc_accounts_t* get_dc_accounts(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "accountsCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_accounts_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcAccounts_createAccountsCPtr(JNIEnv *env, jobject obj, jstring dir) +{ + CHAR_REF(dir); + int writable = 1; + jlong accountsCPtr = (jlong)dc_accounts_new(dirPtr, writable); + CHAR_UNREF(dir); + return accountsCPtr; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_unrefAccountsCPtr(JNIEnv *env, jobject obj) +{ + dc_accounts_unref(get_dc_accounts(env, obj)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcAccounts_getEventEmitterCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_accounts_get_event_emitter(get_dc_accounts(env, obj)); +} + +JNIEXPORT jlong Java_com_b44t_messenger_DcAccounts_getJsonrpcInstanceCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_jsonrpc_init(get_dc_accounts(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_startIo2(JNIEnv *env, jobject obj) +{ + dc_accounts_start_io(get_dc_accounts(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_stopIo(JNIEnv *env, jobject obj) +{ + dc_accounts_stop_io(get_dc_accounts(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_maybeNetwork(JNIEnv *env, jobject obj) +{ + dc_accounts_maybe_network(get_dc_accounts(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_setPushDeviceToken(JNIEnv *env, jobject obj, jstring token) +{ + CHAR_REF(token); + dc_accounts_set_push_device_token(get_dc_accounts(env, obj), tokenPtr); + CHAR_UNREF(token); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcAccounts_backgroundFetch(JNIEnv *env, jobject obj, jint timeout_seconds) +{ + return dc_accounts_background_fetch(get_dc_accounts(env, obj), timeout_seconds) != 0; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcAccounts_stopBackgroundFetch(JNIEnv *env, jobject obj) +{ + dc_accounts_stop_background_fetch(get_dc_accounts(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcAccounts_migrateAccount(JNIEnv *env, jobject obj, jstring dbfile) +{ + CHAR_REF(dbfile); + jint accountId = dc_accounts_migrate_account(get_dc_accounts(env, obj), dbfilePtr); + CHAR_UNREF(dbfile); + return accountId; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcAccounts_removeAccount(JNIEnv *env, jobject obj, jint accountId) +{ + return dc_accounts_remove_account(get_dc_accounts(env, obj), accountId) != 0; +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcAccounts_getAll(JNIEnv *env, jobject obj) +{ + dc_array_t* ca = dc_accounts_get_all(get_dc_accounts(env, obj)); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcAccounts_getAccountCPtr(JNIEnv *env, jobject obj, jint accountId) +{ + return (jlong)dc_accounts_get_account(get_dc_accounts(env, obj), accountId); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcAccounts_getSelectedAccountCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_accounts_get_selected_account(get_dc_accounts(env, obj)); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcAccounts_selectAccount(JNIEnv *env, jobject obj, jint accountId) +{ + return dc_accounts_select_account(get_dc_accounts(env, obj), accountId) != 0; +} + + +/******************************************************************************* + * DcContext + ******************************************************************************/ + + +static dc_context_t* get_dc_context(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "contextCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_context_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_createContextCPtr(JNIEnv *env, jobject obj, jstring osname, jstring dbfile) +{ + CHAR_REF(osname); + CHAR_REF(dbfile) + jlong contextCPtr = (jlong)dc_context_new(osnamePtr, dbfilePtr, NULL); + CHAR_UNREF(dbfile) + CHAR_UNREF(osname); + return contextCPtr; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_open(JNIEnv *env, jobject obj, jstring passphrase) +{ + CHAR_REF(passphrase); + jboolean ret = dc_context_open(get_dc_context(env, obj), passphrasePtr); + CHAR_UNREF(passphrase); + return ret; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_isOpen(JNIEnv *env, jobject obj) +{ + return dc_context_is_open(get_dc_context(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_unrefContextCPtr(JNIEnv *env, jobject obj) +{ + dc_context_unref(get_dc_context(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_getAccountId(JNIEnv *env, jobject obj) +{ + return (jint)dc_get_id(get_dc_context(env, obj)); +} + + +/* DcContext - open/configure/connect/fetch */ + +JNIEXPORT void Java_com_b44t_messenger_DcContext_setStockTranslation(JNIEnv *env, jobject obj, jint stock_id, jstring translation) +{ + CHAR_REF(translation); + dc_set_stock_translation(get_dc_context(env, obj), stock_id, translationPtr); + CHAR_UNREF(translation) +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_setConfigFromQr(JNIEnv *env, jobject obj, jstring qr) +{ + CHAR_REF(qr); + jboolean ret = dc_set_config_from_qr(get_dc_context(env, obj), qrPtr); + CHAR_UNREF(qr); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getBlobdir(JNIEnv *env, jobject obj) +{ + char* temp = dc_get_blobdir(get_dc_context(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getLastError(JNIEnv *env, jobject obj) +{ + char* temp = dc_get_last_error(get_dc_context(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_configure(JNIEnv *env, jobject obj) +{ + dc_configure(get_dc_context(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_stopOngoingProcess(JNIEnv *env, jobject obj) +{ + dc_stop_ongoing_process(get_dc_context(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_isConfigured(JNIEnv *env, jobject obj) +{ + return (jint)dc_is_configured(get_dc_context(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_startIo(JNIEnv *env, jobject obj) +{ + dc_start_io(get_dc_context(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_stopIo(JNIEnv *env, jobject obj) +{ + dc_stop_io(get_dc_context(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_maybeNetwork(JNIEnv *env, jobject obj) +{ + dc_maybe_network(get_dc_context(env, obj)); +} + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getEventEmitterCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_get_event_emitter(get_dc_context(env, obj)); +} + + +/* DcContext - handle contacts */ + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_mayBeValidAddr(JNIEnv *env, jobject obj, jstring addr) +{ + CHAR_REF(addr); + jboolean ret = dc_may_be_valid_addr(addrPtr); + CHAR_UNREF(addr); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_lookupContactIdByAddr(JNIEnv *env, jobject obj, jstring addr) +{ + CHAR_REF(addr); + jint ret = dc_lookup_contact_id_by_addr(get_dc_context(env, obj), addrPtr); + CHAR_UNREF(addr); + return ret; +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getContacts(JNIEnv *env, jobject obj, jint flags, jstring query) +{ + CHAR_REF(query); + dc_array_t* ca = dc_get_contacts(get_dc_context(env, obj), flags, queryPtr); + CHAR_UNREF(query); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getBlockedContacts(JNIEnv *env, jobject obj) +{ + dc_array_t* ca = dc_get_blocked_contacts(get_dc_context(env, obj)); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getContactCPtr(JNIEnv *env, jobject obj, jint contact_id) +{ + return (jlong)dc_get_contact(get_dc_context(env, obj), contact_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_createContact(JNIEnv *env, jobject obj, jstring name, jstring addr) +{ + CHAR_REF(name); + CHAR_REF(addr); + jint ret = (jint)dc_create_contact(get_dc_context(env, obj), namePtr, addrPtr); + CHAR_UNREF(addr); + CHAR_UNREF(name); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_blockContact(JNIEnv *env, jobject obj, jint contact_id, jint block) +{ + dc_block_contact(get_dc_context(env, obj), contact_id, block); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_deleteContact(JNIEnv *env, jobject obj, jint contact_id) +{ + return (jboolean)dc_delete_contact(get_dc_context(env, obj), contact_id); +} + + +/* DcContext - handle chats */ + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getChatlistCPtr(JNIEnv *env, jobject obj, jint listflags, jstring query, jint queryId) +{ + jlong ret; + if (query) { + CHAR_REF(query); + ret = (jlong)dc_get_chatlist(get_dc_context(env, obj), listflags, queryPtr, queryId); + CHAR_UNREF(query); + } + else { + ret = (jlong)dc_get_chatlist(get_dc_context(env, obj), listflags, NULL, queryId); + } + return ret; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getChatCPtr(JNIEnv *env, jobject obj, jint chat_id) +{ + return (jlong)dc_get_chat(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_getChatIdByContactId(JNIEnv *env, jobject obj, jint contact_id) +{ + return (jint)dc_get_chat_id_by_contact_id(get_dc_context(env, obj), contact_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_markseenMsgs(JNIEnv *env, jobject obj, jintArray msg_ids) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + dc_markseen_msgs(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt); + free(msg_ids_ptr); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getChatEncrInfo(JNIEnv *env, jobject obj, jint chat_id) +{ + char* temp = dc_get_chat_encrinfo(get_dc_context(env, obj), chat_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_marknoticedChat(JNIEnv *env, jobject obj, jint chat_id) +{ + dc_marknoticed_chat(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_setChatVisibility(JNIEnv *env, jobject obj, jint chat_id, jint visibility) +{ + dc_set_chat_visibility(get_dc_context(env, obj), chat_id, visibility); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_createChatByContactId(JNIEnv *env, jobject obj, jint contact_id) +{ + return (jint)dc_create_chat_by_contact_id(get_dc_context(env, obj), contact_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_createGroupChat(JNIEnv *env, jobject obj, jstring name) +{ + CHAR_REF(name); + jint ret = (jint)dc_create_group_chat(get_dc_context(env, obj), 0, namePtr); + CHAR_UNREF(name); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_createBroadcastList(JNIEnv *env, jobject obj) +{ + return (jint)dc_create_broadcast_list(get_dc_context(env, obj)); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_isContactInChat(JNIEnv *env, jobject obj, jint chat_id, jint contact_id) +{ + return (jboolean)dc_is_contact_in_chat(get_dc_context(env, obj), chat_id, contact_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_addContactToChat(JNIEnv *env, jobject obj, jint chat_id, jint contact_id) +{ + return (jint)dc_add_contact_to_chat(get_dc_context(env, obj), chat_id, contact_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_removeContactFromChat(JNIEnv *env, jobject obj, jint chat_id, jint contact_id) +{ + return (jint)dc_remove_contact_from_chat(get_dc_context(env, obj), chat_id, contact_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_setDraft(JNIEnv *env, jobject obj, jint chat_id, jobject msg /* NULL=delete */) +{ + dc_set_draft(get_dc_context(env, obj), chat_id, get_dc_msg(env, msg)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getDraftCPtr(JNIEnv *env, jobject obj, jint chat_id) +{ + return (jlong)dc_get_draft(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_setChatName(JNIEnv *env, jobject obj, jint chat_id, jstring name) +{ + CHAR_REF(name); + jint ret = (jint)dc_set_chat_name(get_dc_context(env, obj), chat_id, namePtr); + CHAR_UNREF(name); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_setChatProfileImage(JNIEnv *env, jobject obj, jint chat_id, jstring image/*NULL=delete*/) +{ + CHAR_REF(image); + jint ret = (jint)dc_set_chat_profile_image(get_dc_context(env, obj), chat_id, imagePtr/*CHAR_REF() preserves NULL*/); + CHAR_UNREF(image); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_deleteChat(JNIEnv *env, jobject obj, jint chat_id) +{ + dc_delete_chat(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_blockChat(JNIEnv *env, jobject obj, jint chat_id) +{ + dc_block_chat(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_acceptChat(JNIEnv *env, jobject obj, jint chat_id) +{ + dc_accept_chat(get_dc_context(env, obj), chat_id); +} + + +/* DcContext - handle messages */ + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_getFreshMsgCount(JNIEnv *env, jobject obj, jint chat_id) +{ + return dc_get_fresh_msg_cnt(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_estimateDeletionCount(JNIEnv *env, jobject obj, jboolean from_server, jlong seconds) +{ + return dc_estimate_deletion_cnt(get_dc_context(env, obj), from_server, seconds); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getMsgCPtr(JNIEnv *env, jobject obj, jint id) +{ + return (jlong)dc_get_msg(get_dc_context(env, obj), id); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_createMsgCPtr(JNIEnv *env, jobject obj, jint viewtype) +{ + return (jlong)dc_msg_new(get_dc_context(env, obj), viewtype); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getMsgInfo(JNIEnv *env, jobject obj, jint msg_id) +{ + char* temp = dc_get_msg_info(get_dc_context(env, obj), msg_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_sendEditRequest(JNIEnv *env, jobject obj, jint msg_id, jstring text) +{ + CHAR_REF(text); + dc_send_edit_request(get_dc_context(env, obj), msg_id, textPtr); + CHAR_UNREF(text); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getMsgHtml(JNIEnv *env, jobject obj, jint msg_id) +{ + char* temp = dc_get_msg_html(get_dc_context(env, obj), msg_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_downloadFullMsg(JNIEnv *env, jobject obj, jint msg_id) +{ + dc_download_full_msg(get_dc_context(env, obj), msg_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_deleteMsgs(JNIEnv *env, jobject obj, jintArray msg_ids) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + dc_delete_msgs(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt); + free(msg_ids_ptr); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_sendDeleteRequest(JNIEnv *env, jobject obj, jintArray msg_ids) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + dc_send_delete_request(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt); + free(msg_ids_ptr); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_forwardMsgs(JNIEnv *env, jobject obj, jintArray msg_ids, jint chat_id) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + dc_forward_msgs(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt, chat_id); + free(msg_ids_ptr); +} + +JNIEXPORT void Java_com_b44t_messenger_DcContext_saveMsgs(JNIEnv *env, jobject obj, jintArray msg_ids) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + dc_save_msgs(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt); + free(msg_ids_ptr); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_resendMsgs(JNIEnv *env, jobject obj, jintArray msg_ids) +{ + int msg_ids_cnt = 0; + uint32_t* msg_ids_ptr = jintArray2uint32Pointer(env, msg_ids, &msg_ids_cnt); + jboolean ret = dc_resend_msgs(get_dc_context(env, obj), msg_ids_ptr, msg_ids_cnt) != 0; + free(msg_ids_ptr); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_sendMsg(JNIEnv *env, jobject obj, jint chat_id, jobject msg) +{ + return dc_send_msg(get_dc_context(env, obj), chat_id, get_dc_msg(env, msg)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_sendTextMsg(JNIEnv *env, jobject obj, jint chat_id, jstring text) +{ + CHAR_REF(text); + jint msg_id = dc_send_text_msg(get_dc_context(env, obj), chat_id, textPtr); + CHAR_UNREF(text); + return msg_id; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_sendWebxdcStatusUpdate(JNIEnv *env, jobject obj, jint msg_id, jstring payload) +{ + CHAR_REF(payload); + jboolean ret = dc_send_webxdc_status_update(get_dc_context(env, obj), msg_id, payloadPtr, NULL) != 0; + CHAR_UNREF(payload); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getWebxdcStatusUpdates(JNIEnv *env, jobject obj, jint msg_id, jint last_known_serial) +{ + char* temp = dc_get_webxdc_status_updates(get_dc_context(env, obj), msg_id, last_known_serial); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_initWebxdcIntegration(JNIEnv *env, jobject obj, jint chat_id) +{ + return dc_init_webxdc_integration(get_dc_context(env, obj), chat_id); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_setWebxdcIntegration(JNIEnv *env, jobject obj, jstring file) +{ + CHAR_REF(file); + dc_set_webxdc_integration(get_dc_context(env, obj), filePtr); + CHAR_UNREF(file); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_addDeviceMsg(JNIEnv *env, jobject obj, jstring label, jobject msg) +{ + CHAR_REF(label); + int msg_id = dc_add_device_msg(get_dc_context(env, obj), labelPtr, get_dc_msg(env, msg)); + CHAR_UNREF(label); + return msg_id; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_wasDeviceMsgEverAdded(JNIEnv *env, jobject obj, jstring label) +{ + CHAR_REF(label); + jboolean ret = dc_was_device_msg_ever_added(get_dc_context(env, obj), labelPtr) != 0; + CHAR_UNREF(label); + return ret; +} + + +/* DcContext - handle config */ + +JNIEXPORT void Java_com_b44t_messenger_DcContext_setConfig(JNIEnv *env, jobject obj, jstring key, jstring value /*may be NULL*/) +{ + CHAR_REF(key); + CHAR_REF(value); + dc_set_config(get_dc_context(env, obj), keyPtr, valuePtr /*is NULL if value is NULL, CHAR_REF() handles this*/); + CHAR_UNREF(key); + CHAR_UNREF(value); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getConfig(JNIEnv *env, jobject obj, jstring key) +{ + CHAR_REF(key); + char* temp = dc_get_config(get_dc_context(env, obj), keyPtr); + jstring ret = NULL; + if (temp) { + ret = JSTRING_NEW(temp); + } + dc_str_unref(temp); + CHAR_UNREF(key); + return ret; +} + + +/* DcContext - out-of-band verification */ + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_checkQrCPtr(JNIEnv *env, jobject obj, jstring qr) +{ + CHAR_REF(qr); + jlong ret = (jlong)dc_check_qr(get_dc_context(env, obj), qrPtr); + CHAR_UNREF(qr); + return ret; +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getSecurejoinQr(JNIEnv *env, jobject obj, jint chat_id) +{ + char* temp = dc_get_securejoin_qr(get_dc_context(env, obj), chat_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getSecurejoinQrSvg(JNIEnv *env, jobject obj, jint chat_id) +{ + char* temp = dc_get_securejoin_qr_svg(get_dc_context(env, obj), chat_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_createQrSvg(JNIEnv *env, jobject obj, jstring payload) +{ + CHAR_REF(payload); + char* temp = dc_create_qr_svg(payloadPtr); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + CHAR_UNREF(payload); + return ret; +} + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_joinSecurejoin(JNIEnv *env, jobject obj, jstring qr) +{ + CHAR_REF(qr); + jint ret = dc_join_securejoin(get_dc_context(env, obj), qrPtr); + CHAR_UNREF(qr); + return ret; +} + + +/* DcContext - misc. */ + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getInfo(JNIEnv *env, jobject obj) +{ + char* temp = dc_get_info(get_dc_context(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_getConnectivity(JNIEnv *env, jobject obj) +{ + return dc_get_connectivity(get_dc_context(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getConnectivityHtml(JNIEnv *env, jobject obj) +{ + char* temp = dc_get_connectivity_html(get_dc_context(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getOauth2Url(JNIEnv *env, jobject obj, jstring addr, jstring redirectUrl) +{ + CHAR_REF(addr); + CHAR_REF(redirectUrl); + char* temp = dc_get_oauth2_url(get_dc_context(env, obj), addrPtr, redirectUrlPtr); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + CHAR_UNREF(redirectUrl); + CHAR_UNREF(addr); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getContactEncrInfo(JNIEnv *env, jobject obj, jint contact_id) +{ + char* temp = dc_get_contact_encrinfo(get_dc_context(env, obj), contact_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_initiateKeyTransfer(JNIEnv *env, jobject obj) +{ + jstring setup_code = NULL; + char* temp = dc_initiate_key_transfer(get_dc_context(env, obj)); + if (temp) { + setup_code = JSTRING_NEW(temp); + dc_str_unref(temp); + } + return setup_code; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_imex(JNIEnv *env, jobject obj, jint what, jstring dir) +{ + CHAR_REF(dir); + dc_imex(get_dc_context(env, obj), what, dirPtr, ""); + CHAR_UNREF(dir); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_imexHasBackup(JNIEnv *env, jobject obj, jstring dir) +{ + CHAR_REF(dir); + jstring ret = NULL; + char* temp = dc_imex_has_backup(get_dc_context(env, obj), dirPtr); + if (temp) { + ret = JSTRING_NEW(temp); + dc_str_unref(temp); + } + CHAR_UNREF(dir); + return ret; /* may be NULL! */ +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_newBackupProviderCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_backup_provider_new(get_dc_context(env, obj)); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_receiveBackup(JNIEnv *env, jobject obj, jstring qr) +{ + CHAR_REF(qr); + jboolean ret = dc_receive_backup(get_dc_context(env, obj), qrPtr); + CHAR_UNREF(qr); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_addAddressBook(JNIEnv *env, jobject obj, jstring adrbook) +{ + CHAR_REF(adrbook); + int modify_count = dc_add_address_book(get_dc_context(env, obj), adrbookPtr); + CHAR_UNREF(adrbook); + return modify_count; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContext_sendLocationsToChat(JNIEnv *env, jobject obj, jint chat_id, jint seconds) +{ + dc_send_locations_to_chat(get_dc_context(env, obj), chat_id, seconds); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_isSendingLocationsToChat(JNIEnv *env, jobject obj, jint chat_id) +{ + return (dc_is_sending_locations_to_chat(get_dc_context(env, obj), chat_id)!=0); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_setLocation(JNIEnv *env, jobject obj, jfloat latitude, jfloat longitude, jfloat accuracy) +{ + return (dc_set_location(get_dc_context(env, obj), latitude, longitude, accuracy)!=0); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContext_getProviderFromEmailWithDnsCPtr(JNIEnv *env, jobject obj, jstring email) +{ + CHAR_REF(email); + jlong ret = (jlong)dc_provider_new_from_email_with_dns(get_dc_context(env, obj), emailPtr); + CHAR_UNREF(email); + return ret; +} + + +/******************************************************************************* + * DcEventEmitter + ******************************************************************************/ + + +static dc_event_emitter_t* get_dc_event_emitter(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "eventEmitterCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_event_emitter_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcEventEmitter_unrefEventEmitterCPtr(JNIEnv *env, jobject obj) +{ + dc_event_emitter_unref(get_dc_event_emitter(env, obj)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcEventEmitter_getNextEventCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_get_next_event(get_dc_event_emitter(env, obj)); +} + + +/******************************************************************************* + * DcEvent + ******************************************************************************/ + + +static dc_event_t* get_dc_event(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "eventCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_event_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcEvent_unrefEventCPtr(JNIEnv *env, jobject obj) +{ + dc_event_unref(get_dc_event(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcEvent_getId(JNIEnv *env, jobject obj) +{ + return dc_event_get_id(get_dc_event(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcEvent_getData1Int(JNIEnv *env, jobject obj) +{ + return dc_event_get_data1_int(get_dc_event(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcEvent_getData2Int(JNIEnv *env, jobject obj) +{ + return dc_event_get_data2_int(get_dc_event(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcEvent_getData2Str(JNIEnv *env, jobject obj) +{ + char* temp = dc_event_get_data2_str(get_dc_event(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jbyteArray Java_com_b44t_messenger_DcEvent_getData2Blob(JNIEnv *env, jobject obj) +{ + jbyteArray ret = NULL; + dc_event_t* event = get_dc_event(env, obj); + + size_t ptrSize = dc_event_get_data2_int(event); + char* ptr = dc_event_get_data2_str(get_dc_event(env, obj)); + ret = ptr2jbyteArray(env, ptr, ptrSize); + dc_str_unref(ptr); + + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcEvent_getAccountId(JNIEnv *env, jobject obj) +{ + return (jint)dc_event_get_account_id(get_dc_event(env, obj)); +} + + +/******************************************************************************* + * DcChatlist + ******************************************************************************/ + + +static dc_chatlist_t* get_dc_chatlist(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "chatlistCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_chatlist_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcChatlist_unrefChatlistCPtr(JNIEnv *env, jobject obj) +{ + dc_chatlist_unref(get_dc_chatlist(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChatlist_getCnt(JNIEnv *env, jobject obj) +{ + return dc_chatlist_get_cnt(get_dc_chatlist(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChatlist_getChatId(JNIEnv *env, jobject obj, jint index) +{ + return dc_chatlist_get_chat_id(get_dc_chatlist(env, obj), index); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcChatlist_getChatCPtr(JNIEnv *env, jobject obj, jint index) +{ + dc_chatlist_t* chatlist = get_dc_chatlist(env, obj); + return (jlong)dc_get_chat(dc_chatlist_get_context(chatlist), dc_chatlist_get_chat_id(chatlist, index)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChatlist_getMsgId(JNIEnv *env, jobject obj, jint index) +{ + return dc_chatlist_get_msg_id(get_dc_chatlist(env, obj), index); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcChatlist_getMsgCPtr(JNIEnv *env, jobject obj, jint index) +{ + dc_chatlist_t* chatlist = get_dc_chatlist(env, obj); + return (jlong)dc_get_msg(dc_chatlist_get_context(chatlist), dc_chatlist_get_msg_id(chatlist, index)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcChatlist_getSummaryCPtr(JNIEnv *env, jobject obj, jint index, jlong chatCPtr) +{ + return (jlong)dc_chatlist_get_summary(get_dc_chatlist(env, obj), index, (dc_chat_t*)chatCPtr); +} + + +/******************************************************************************* + * DcChat + ******************************************************************************/ + + +static dc_chat_t* get_dc_chat(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "chatCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_chat_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcChat_unrefChatCPtr(JNIEnv *env, jobject obj) +{ + dc_chat_unref(get_dc_chat(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChat_getId(JNIEnv *env, jobject obj) +{ + return dc_chat_get_id(get_dc_chat(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChat_getType(JNIEnv *env, jobject obj) +{ + return dc_chat_get_type(get_dc_chat(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChat_getVisibility(JNIEnv *env, jobject obj) +{ + return dc_chat_get_visibility(get_dc_chat(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcChat_getName(JNIEnv *env, jobject obj) +{ + char* temp = dc_chat_get_name(get_dc_chat(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcChat_getMailinglistAddr(JNIEnv *env, jobject obj) +{ + char* temp = dc_chat_get_mailinglist_addr(get_dc_chat(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcChat_getProfileImage(JNIEnv *env, jobject obj) +{ + char* temp = dc_chat_get_profile_image(get_dc_chat(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcChat_getColor(JNIEnv *env, jobject obj) +{ + return dc_chat_get_color(get_dc_chat(env, obj)); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isEncrypted(JNIEnv *env, jobject obj) +{ + return dc_chat_is_encrypted(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isUnpromoted(JNIEnv *env, jobject obj) +{ + return dc_chat_is_unpromoted(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isSelfTalk(JNIEnv *env, jobject obj) +{ + return dc_chat_is_self_talk(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isDeviceTalk(JNIEnv *env, jobject obj) +{ + return dc_chat_is_device_talk(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_canSend(JNIEnv *env, jobject obj) +{ + return dc_chat_can_send(get_dc_chat(env, obj))!=0; +} + + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isSendingLocations(JNIEnv *env, jobject obj) +{ + return dc_chat_is_sending_locations(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isContactRequest(JNIEnv *env, jobject obj) +{ + return dc_chat_is_contact_request(get_dc_chat(env, obj))!=0; +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getChatMedia(JNIEnv *env, jobject obj, jint chat_id, jint type1, jint type2, jint type3) +{ + dc_array_t* ca = dc_get_chat_media(get_dc_context(env, obj), chat_id, type1, type2, type3); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getChatMsgs(JNIEnv *env, jobject obj, jint chat_id, jint flags, jint marker1before) +{ + dc_array_t* ca = dc_get_chat_msgs(get_dc_context(env, obj), chat_id, flags, marker1before); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_searchMsgs(JNIEnv *env, jobject obj, jint chat_id, jstring query) +{ + CHAR_REF(query); + dc_array_t* ca = dc_search_msgs(get_dc_context(env, obj), chat_id, queryPtr); + CHAR_UNREF(query); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getFreshMsgs(JNIEnv *env, jobject obj) +{ + dc_array_t* ca = dc_get_fresh_msgs(get_dc_context(env, obj)); + return dc_array2jintArray_n_unref(env, ca); +} + + +JNIEXPORT jintArray Java_com_b44t_messenger_DcContext_getChatContacts(JNIEnv *env, jobject obj, jint chat_id) +{ + dc_array_t* ca = dc_get_chat_contacts(get_dc_context(env, obj), chat_id); + return dc_array2jintArray_n_unref(env, ca); +} + +JNIEXPORT jint Java_com_b44t_messenger_DcContext_getChatEphemeralTimer(JNIEnv *env, jobject obj, jint chat_id) +{ + return dc_get_chat_ephemeral_timer(get_dc_context(env, obj), chat_id); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_setChatEphemeralTimer(JNIEnv *env, jobject obj, jint chat_id, jint timer) +{ + return dc_set_chat_ephemeral_timer(get_dc_context(env, obj), chat_id, timer); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_setChatMuteDuration(JNIEnv *env, jobject obj, jint chat_id, jlong duration) +{ + return dc_set_chat_mute_duration(get_dc_context(env, obj), chat_id, duration); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcChat_isMuted(JNIEnv *env, jobject obj) +{ + return dc_chat_is_muted(get_dc_chat(env, obj)); +} + + +/******************************************************************************* + * DcMsg + ******************************************************************************/ + + +static dc_msg_t* get_dc_msg(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (env && obj) { + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "msgCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_msg_t*)(*env)->GetLongField(env, obj, fid); + } + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_unrefMsgCPtr(JNIEnv *env, jobject obj) +{ + dc_msg_unref(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getId(JNIEnv *env, jobject obj) +{ + return dc_msg_get_id(get_dc_msg(env, obj)); +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getText(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_text(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getSubject(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_subject(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getTimestamp(JNIEnv *env, jobject obj) +{ + return JTIMESTAMP(dc_msg_get_timestamp(get_dc_msg(env, obj))); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getSortTimestamp(JNIEnv *env, jobject obj) +{ + return JTIMESTAMP(dc_msg_get_sort_timestamp(get_dc_msg(env, obj))); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_hasDeviatingTimestamp(JNIEnv *env, jobject obj) +{ + return dc_msg_has_deviating_timestamp(get_dc_msg(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_hasLocation(JNIEnv *env, jobject obj) +{ + return dc_msg_has_location(get_dc_msg(env, obj))!=0; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getViewType(JNIEnv *env, jobject obj) +{ + return dc_msg_get_viewtype(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getInfoType(JNIEnv *env, jobject obj) +{ + return dc_msg_get_info_type(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getInfoContactId(JNIEnv *env, jobject obj) +{ + return dc_msg_get_info_contact_id(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getState(JNIEnv *env, jobject obj) +{ + return dc_msg_get_state(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getDownloadState(JNIEnv *env, jobject obj) +{ + return dc_msg_get_download_state(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getChatId(JNIEnv *env, jobject obj) +{ + return dc_msg_get_chat_id(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getFromId(JNIEnv *env, jobject obj) +{ + return dc_msg_get_from_id(get_dc_msg(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getWidth(JNIEnv *env, jobject obj, jint def) +{ + jint ret = (jint)dc_msg_get_width(get_dc_msg(env, obj)); + return ret? ret : def; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getHeight(JNIEnv *env, jobject obj, jint def) +{ + jint ret = (jint)dc_msg_get_height(get_dc_msg(env, obj)); + return ret? ret : def; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getDuration(JNIEnv *env, jobject obj) +{ + return dc_msg_get_duration(get_dc_msg(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_lateFilingMediaSize(JNIEnv *env, jobject obj, jint width, jint height, jint duration) +{ + dc_msg_latefiling_mediasize(get_dc_msg(env, obj), width, height, duration); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getFilebytes(JNIEnv *env, jobject obj) +{ + return (jlong)dc_msg_get_filebytes(get_dc_msg(env, obj)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getSummaryCPtr(JNIEnv *env, jobject obj, jlong chatCPtr) +{ + return (jlong)dc_msg_get_summary(get_dc_msg(env, obj), (dc_chat_t*)chatCPtr); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getSummarytext(JNIEnv *env, jobject obj, jint approx_characters) +{ + char* temp = dc_msg_get_summarytext(get_dc_msg(env, obj), approx_characters); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getOverrideSenderName(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_override_sender_name(get_dc_msg(env, obj)); + jstring ret = NULL; + if (temp) { + ret = JSTRING_NEW(temp); + } + dc_str_unref(temp); + return ret; // null if there is no override-sender-name +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_showPadlock(JNIEnv *env, jobject obj) +{ + return dc_msg_get_showpadlock(get_dc_msg(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getFile(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_file(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getFilemime(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_filemime(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getFilename(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_filename(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jbyteArray Java_com_b44t_messenger_DcMsg_getWebxdcBlob(JNIEnv *env, jobject obj, jstring filename) +{ + jbyteArray ret = NULL; + CHAR_REF(filename) + size_t ptrSize = 0; + char* ptr = dc_msg_get_webxdc_blob(get_dc_msg(env, obj), filenamePtr, &ptrSize); + ret = ptr2jbyteArray(env, ptr, ptrSize); + dc_str_unref(ptr); + CHAR_UNREF(filename) + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getWebxdcInfoJson(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_webxdc_info(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getWebxdcHref(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_webxdc_href(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_isForwarded(JNIEnv *env, jobject obj) +{ + return dc_msg_is_forwarded(get_dc_msg(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_isInfo(JNIEnv *env, jobject obj) +{ + return dc_msg_is_info(get_dc_msg(env, obj))!=0; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_hasHtml(JNIEnv *env, jobject obj) +{ + return dc_msg_has_html(get_dc_msg(env, obj))!=0; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getSetupCodeBegin(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_setupcodebegin(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setSubject(JNIEnv *env, jobject obj, jstring text) +{ + CHAR_REF(text); + dc_msg_set_subject(get_dc_msg(env, obj), textPtr); + CHAR_UNREF(text); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setText(JNIEnv *env, jobject obj, jstring text) +{ + CHAR_REF(text); + dc_msg_set_text(get_dc_msg(env, obj), textPtr); + CHAR_UNREF(text); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setHtml(JNIEnv *env, jobject obj, jstring text) +{ + CHAR_REF(text); + dc_msg_set_html(get_dc_msg(env, obj), textPtr); + CHAR_UNREF(text); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_forceSticker(JNIEnv *env, jobject obj) +{ + dc_msg_force_sticker(get_dc_msg(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getPOILocation(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_poi_location(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_isEdited(JNIEnv *env, jobject obj) +{ + return dc_msg_is_edited(get_dc_msg(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setFileAndDeduplicate(JNIEnv *env, jobject obj, jstring file, jstring name, jstring filemime) +{ + CHAR_REF(file); + CHAR_REF(name); + CHAR_REF(filemime); + dc_msg_set_file_and_deduplicate(get_dc_msg(env, obj), filePtr, namePtr, filemimePtr); + CHAR_UNREF(filemime); + CHAR_UNREF(name); + CHAR_UNREF(file); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setDimension(JNIEnv *env, jobject obj, int width, int height) +{ + dc_msg_set_dimension(get_dc_msg(env, obj), width, height); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setDuration(JNIEnv *env, jobject obj, int duration) +{ + dc_msg_set_duration(get_dc_msg(env, obj), duration); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setLocation(JNIEnv *env, jobject obj, jfloat latitude, jfloat longitude) +{ + dc_msg_set_location(get_dc_msg(env, obj), latitude, longitude); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcMsg_setQuoteCPtr(JNIEnv *env, jobject obj, jlong quoteCPtr) +{ + dc_msg_set_quote(get_dc_msg(env, obj), (dc_msg_t*)quoteCPtr); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getQuotedText(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_quoted_text(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getQuotedMsgCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_msg_get_quoted_msg(get_dc_msg(env, obj)); +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcMsg_getParentCPtr(JNIEnv *env, jobject obj) +{ + return (jlong)dc_msg_get_parent(get_dc_msg(env, obj)); +} + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getOriginalMsgId(JNIEnv *env, jobject obj) +{ + return (jint)dc_msg_get_original_msg_id(get_dc_msg(env, obj)); +} + +JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getSavedMsgId(JNIEnv *env, jobject obj) +{ + return (jint)dc_msg_get_saved_msg_id(get_dc_msg(env, obj)); +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getError(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_error(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +/******************************************************************************* + * DcContact + ******************************************************************************/ + + +static dc_contact_t* get_dc_contact(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "contactCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_contact_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcContact_unrefContactCPtr(JNIEnv *env, jobject obj) +{ + dc_contact_unref(get_dc_contact(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContact_getId(JNIEnv *env, jobject obj) +{ + return dc_contact_get_id(get_dc_contact(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getName(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_name(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getAuthName(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_auth_name(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getDisplayName(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_display_name(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getAddr(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_addr(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getProfileImage(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_profile_image(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContact_getColor(JNIEnv *env, jobject obj) +{ + return dc_contact_get_color(get_dc_contact(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContact_getStatus(JNIEnv *env, jobject obj) +{ + char* temp = dc_contact_get_status(get_dc_contact(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcContact_getLastSeen(JNIEnv *env, jobject obj) +{ + return JTIMESTAMP(dc_contact_get_last_seen(get_dc_contact(env, obj))); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContact_wasSeenRecently(JNIEnv *env, jobject obj) +{ + return (jboolean)(dc_contact_was_seen_recently(get_dc_contact(env, obj))!=0); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContact_isBlocked(JNIEnv *env, jobject obj) +{ + return (jboolean)(dc_contact_is_blocked(get_dc_contact(env, obj))!=0); +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContact_isVerified(JNIEnv *env, jobject obj) +{ + return dc_contact_is_verified(get_dc_contact(env, obj))==2; +} + + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContact_isKeyContact(JNIEnv *env, jobject obj) +{ + return dc_contact_is_key_contact(get_dc_contact(env, obj))==1; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcContact_getVerifierId(JNIEnv *env, jobject obj) +{ + return dc_contact_get_verifier_id(get_dc_contact(env, obj)); +} + +JNIEXPORT jboolean Java_com_b44t_messenger_DcContact_isBot(JNIEnv *env, jobject obj) +{ + return dc_contact_is_bot(get_dc_contact(env, obj)) != 0; +} + + +/******************************************************************************* + * DcLot + ******************************************************************************/ + + +static dc_lot_t* get_dc_lot(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "lotCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_lot_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcLot_getText1(JNIEnv *env, jobject obj) +{ + char* temp = dc_lot_get_text1(get_dc_lot(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcLot_getText1Meaning(JNIEnv *env, jobject obj) +{ + return dc_lot_get_text1_meaning(get_dc_lot(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcLot_getText2(JNIEnv *env, jobject obj) +{ + char* temp = dc_lot_get_text2(get_dc_lot(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jlong Java_com_b44t_messenger_DcLot_getTimestamp(JNIEnv *env, jobject obj) +{ + return JTIMESTAMP(dc_lot_get_timestamp(get_dc_lot(env, obj))); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcLot_getState(JNIEnv *env, jobject obj) +{ + return dc_lot_get_state(get_dc_lot(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcLot_getId(JNIEnv *env, jobject obj) +{ + return dc_lot_get_id(get_dc_lot(env, obj)); +} + + +JNIEXPORT void Java_com_b44t_messenger_DcLot_unrefLotCPtr(JNIEnv *env, jobject obj) +{ + dc_lot_unref(get_dc_lot(env, obj)); +} + + +/******************************************************************************* + * DcBackupProvider + ******************************************************************************/ + + +static dc_backup_provider_t* get_dc_backup_provider(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "backupProviderCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_backup_provider_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcBackupProvider_unrefBackupProviderCPtr(JNIEnv *env, jobject obj) +{ + dc_backup_provider_unref(get_dc_backup_provider(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcBackupProvider_getQr(JNIEnv *env, jobject obj) +{ + char* temp = dc_backup_provider_get_qr(get_dc_backup_provider(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcBackupProvider_getQrSvg(JNIEnv *env, jobject obj) +{ + char* temp = dc_backup_provider_get_qr_svg(get_dc_backup_provider(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcBackupProvider_waitForReceiver(JNIEnv *env, jobject obj) +{ + dc_backup_provider_wait(get_dc_backup_provider(env, obj)); +} + + +/******************************************************************************* + * DcProvider + ******************************************************************************/ + + +static dc_provider_t* get_dc_provider(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "providerCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_provider_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcProvider_unrefProviderCPtr(JNIEnv *env, jobject obj) +{ + dc_provider_unref(get_dc_provider(env, obj)); +} + + +JNIEXPORT jint Java_com_b44t_messenger_DcProvider_getStatus(JNIEnv *env, jobject obj) +{ + return (jint)dc_provider_get_status(get_dc_provider(env, obj)); +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcProvider_getBeforeLoginHint(JNIEnv *env, jobject obj) +{ + char* temp = dc_provider_get_before_login_hint(get_dc_provider(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcProvider_getOverviewPage(JNIEnv *env, jobject obj) +{ + char* temp = dc_provider_get_overview_page(get_dc_provider(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + +/******************************************************************************* + * DcJsonrpcInstance + ******************************************************************************/ + +static dc_jsonrpc_instance_t* get_dc_jsonrpc_instance(JNIEnv *env, jobject obj) +{ + static jfieldID fid = 0; + if (fid==0) { + jclass cls = (*env)->GetObjectClass(env, obj); + fid = (*env)->GetFieldID(env, cls, "jsonrpcInstanceCPtr", "J" /*Signature, J=long*/); + } + if (fid) { + return (dc_jsonrpc_instance_t*)(*env)->GetLongField(env, obj, fid); + } + return NULL; +} + + +JNIEXPORT void Java_com_b44t_messenger_DcJsonrpcInstance_unrefJsonrpcInstanceCPtr(JNIEnv *env, jobject obj) +{ + dc_jsonrpc_unref(get_dc_jsonrpc_instance(env, obj)); +} + +JNIEXPORT void Java_com_b44t_messenger_DcJsonrpcInstance_request(JNIEnv *env, jobject obj, jstring request) +{ + CHAR_REF(request); + dc_jsonrpc_request(get_dc_jsonrpc_instance(env, obj), requestPtr); + CHAR_UNREF(request); +} + +JNIEXPORT jstring Java_com_b44t_messenger_DcJsonrpcInstance_getNextResponse(JNIEnv *env, jobject obj) +{ + char* temp = dc_jsonrpc_next_response(get_dc_jsonrpc_instance(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} diff --git a/makefile b/makefile new file mode 100644 index 0000000000000000000000000000000000000000..08ee426c941b928cb77dbe62f9d26c6fd5da1e65 --- /dev/null +++ b/makefile @@ -0,0 +1,83 @@ +.PHONY: apk +apk: + ./scripts/rebrand.sh + ./gradlew --offline assembleGplayRelease + ./scripts/undo_rebrand.sh + +.PHONY: aab +aab: + ./scripts/rebrand.sh + sed -i 's/signingConfigs.releaseApk/signingConfigs.releaseBundle/g' build.gradle + ./gradlew --offline bundleGplayRelease + sed -i 's/signingConfigs.releaseBundle/signingConfigs.releaseApk/g' build.gradle + ./scripts/undo_rebrand.sh + +.PHONY: foss +foss: + ./scripts/rebrand.sh + ./gradlew --offline assembleFossRelease + ./scripts/undo_rebrand.sh + +.PHONY: install +install: + adb -d install -r build/outputs/apk/gplay/release/*universal*.apk + +.PHONY: emulator-install +emulator-install: + adb install -r build/outputs/apk/gplay/release/*universal*.apk + +.PHONY: fetch +fetch: + git fetch upstream + +.PHONY: clean +clean: + ./gradlew --offline clean + + +# CORE: + +.PHONY: fetch-core +fetch-core: + cd ../core && git fetch upstream + +.PHONY: core +core: + rmdir jni/deltachat-core-rust; mv ../core jni/deltachat-core-rust; true + ./scripts/ndk-make.sh; true + ./scripts/undo_rebrand.sh + mv jni/deltachat-core-rust ../core + mkdir jni/deltachat-core-rust + +.PHONY: core-fast +core-fast: + rmdir jni/deltachat-core-rust; mv ../core jni/deltachat-core-rust; true + ./scripts/ndk-make.sh arm64-v8a; true + ./scripts/undo_rebrand.sh + mv jni/deltachat-core-rust ../core + mkdir jni/deltachat-core-rust + +.PHONY: core-v7 +core-v7: + rmdir jni/deltachat-core-rust; mv ../core jni/deltachat-core-rust; true + ./scripts/ndk-make.sh armeabi-v7a; true + ./scripts/undo_rebrand.sh + mv jni/deltachat-core-rust ../core + mkdir jni/deltachat-core-rust + +.PHONY: core-x86 +core-x86: + rmdir jni/deltachat-core-rust; mv ../core jni/deltachat-core-rust; true + ./scripts/ndk-make.sh x86; true + ./scripts/undo_rebrand.sh + mv jni/deltachat-core-rust ../core + mkdir jni/deltachat-core-rust + +.PHONY: link +link: + rmdir jni/deltachat-core-rust; mv ../core jni/deltachat-core-rust; true + +.PHONY: unlink +unlink: + mv jni/deltachat-core-rust ../core + mkdir jni/deltachat-core-rust diff --git a/proguard-rules.pro b/proguard-rules.pro new file mode 100644 index 0000000000000000000000000000000000000000..6a29a43bd52264e63ce3306c12a6b0c44ed849e3 --- /dev/null +++ b/proguard-rules.pro @@ -0,0 +1,15 @@ +# native methods +-keep class com.b44t.messenger.** { * ; } + +# Keep metadata needed by the JSON parser +-keep class chat.delta.rpc.** { * ; } +-keepattributes *Annotation*,EnclosingMethod,Signature +-keepnames class com.fasterxml.jackson.** { *; } + +# bug with video recoder +-keep class com.coremedia.iso.** { *; } + +# unused SealedData constructor needed by JsonUtils +-keep class org.thoughtcrime.securesms.crypto.KeyStoreHelper* { *; } + +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector diff --git a/scripts/add-language.sh b/scripts/add-language.sh new file mode 100644 index 0000000000000000000000000000000000000000..7e2afa573c43559a88e0984fb0de57a7fe6a7abc --- /dev/null +++ b/scripts/add-language.sh @@ -0,0 +1,24 @@ +# add a language, must be executed from the repo root + +if [ $# -eq 0 ] +then + echo "Please specify the language to add as the first argument (dk, ru etc.)" + exit +fi + +LANG=$1 +RES=src/main/res + +mkdir $RES/values-$LANG/ + +cp $RES/values/strings.xml $RES/values-$LANG/strings.xml + +# set time to old date because transifex may have different file times +# and does not overwrite old file +# (using -t as sth. as -d "100 days ago" does not work on mac) +touch -t 201901010000 $RES/values-$LANG/strings.xml + +echo "$RES/values-$LANG/strings.xml added:" +echo "- if needed, language mappings can be added to .tx/config" +echo "- pull translations using ./scripts/tx-pull-translations.sh" +echo " (on problems, 'tx -d pull' gives verbose output)" diff --git a/scripts/check-translations.sh b/scripts/check-translations.sh new file mode 100644 index 0000000000000000000000000000000000000000..ffe5abd7d05c07fc190a76d4216b57d2ea5f76f3 --- /dev/null +++ b/scripts/check-translations.sh @@ -0,0 +1,22 @@ +echo potential errors, if any: + +RES=./src/main/res + +# a space after the percent sign +# results in an IllegalFormatException in getString() +grep --include='strings.xml' -r '\% [12]' $RES +grep --include='strings.xml' -r '\%[$]' $RES +grep --include='strings.xml' -r '\$ ' $RES +grep --include='strings.xml' -r ' \$' $RES + +# check for broken usage of escape sequences: +# - alert on `\ n`, `\ N`, `\n\Another paragraph` and so on +# - allow only `\n`, `\"`, `\'` and `\’` +# (`’` might not be escaped, but it is done often eg. in "sq", so we allow that for now) +grep --include='strings.xml' -r "\\\\[^n\"'’]" $RES + +# check for usage of a single `&` - this has to be an `&` +grep --include='strings.xml' -r "&[^a]" $RES + +# single
is not needed - and not allowed in xml, leading to error "matching end tag missing" +grep --include='strings.xml' -r "" + exit +fi + +echo "==================== ANDROID USAGE ====================" +grep --exclude={*.apk,*.a,*.o,*.so,strings.xml,*symbols.zip} --exclude-dir={.git,.gradle,obj,release,.idea,build,deltachat-core-rust} -ri $TEXT . + +echo "==================== IOS USAGE ====================" +grep --exclude=*.strings* --exclude-dir={.git,libraries,Pods,deltachat-ios.xcodeproj,deltachat-ios.xcworkspace} -ri $TEXT ../deltachat-ios/ + +echo "==================== DESKTOP USAGE ====================" +grep --exclude-dir={.cache,.git,html-dist,node_modules,_locales} -ri $TEXT ../deltachat-desktop/ + +echo "==================== JSONRPC USAGE ====================" +grep --exclude-dir={.git} -ri $TEXT ../chatmail/core/deltachat-jsonrpc + +echo "==================== UBUNTU TOUCH USAGE ====================" +grep --exclude-dir={.git} -ri $TEXT ../deltatouch/ + diff --git a/scripts/install-toolchains.sh b/scripts/install-toolchains.sh new file mode 100644 index 0000000000000000000000000000000000000000..0eb8522778e0b58ee844d6baa367416c972cb818 --- /dev/null +++ b/scripts/install-toolchains.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# +# Installs Rust cross-compilation toolchains for all supported architectures. +# +set -e +TARGETS="armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android" +RUSTUP_TOOLCHAIN=$(cat "$(dirname "$0")/rust-toolchain") +rustup install "$RUSTUP_TOOLCHAIN" +rustup target add $TARGETS --toolchain "$RUSTUP_TOOLCHAIN" diff --git a/scripts/ndk-make.sh b/scripts/ndk-make.sh new file mode 100644 index 0000000000000000000000000000000000000000..91954282d647aa0910fca0abd0a30dc3455a5093 --- /dev/null +++ b/scripts/ndk-make.sh @@ -0,0 +1,183 @@ +#!/bin/sh + +# If you want to speed up compilation, you can run this script with your +# architecture as an argument: +# +# scripts/ndk-make.sh arm64-v8a +# +# Possible values are armeabi-v7a, arm64-v8a, x86 and x86_64. +# +# To build the core in debug mode, run with "--debug" argument in the beginning: +# +# scripts/ndk-make.sh --debug arm64-v8a +# +# You should be able to find out your architecture by running: +# +# adb shell uname -m +# +# Use an app like: https://f-droid.org/packages/com.kgurgul.cpuinfo/ +# +# Or you just guess your phone's architecture to be arm64-v8a and if you +# guessed wrongly, a warning message will pop up inside the app, telling +# you what the correct one is. +# +# The values in the following lines mean the same: +# +# armeabi-v7a, armv7 and arm +# arm64-v8a, aarch64 and arm64 +# x86 and i686 +# (there are no synonyms for x86_64) +# +# +# If you put this in your .bashrc, then you can directly build and +# deploy DeltaChat from the jni/deltachat-core-rust directory by +# typing `nmake`: +# +# nmake() {(cd ../..; scripts/ndk-make.sh arm64-v8a && ./gradlew installFossDebug; notify-send "install finished")} +# +# +# If anything doesn't work, please open an issue!! + +set -e +echo "starting time: `date`" + +export CFLAGS="-fno-unwind-tables -fno-exceptions -fno-asynchronous-unwind-tables -fomit-frame-pointer -fvisibility=hidden" + +: "${ANDROID_NDK_ROOT:=$ANDROID_NDK_HOME}" +: "${ANDROID_NDK_ROOT:=$ANDROID_NDK}" +if test -z "$ANDROID_NDK_ROOT"; then + echo "ANDROID_NDK_ROOT is not set" + exit 1 +fi + +# for reproducible build: +export RUSTFLAGS="-C link-args=-Wl,--build-id=none --remap-path-prefix=$HOME/.cargo= --remap-path-prefix=$(realpath $(dirname $(dirname "$0")))=" +export SOURCE_DATE_EPOCH=1 +# always use the same path to NDK: +rm -f /tmp/android-ndk-root +ln -s "$ANDROID_NDK_ROOT" /tmp/android-ndk-root +ANDROID_NDK_ROOT=/tmp/android-ndk-root + +echo Setting CARGO_TARGET environment variables. + +if test -z "$NDK_HOST_TAG"; then + KERNEL="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + + if test "$ARCH" = "arm64" && test ! -f "$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/$KERNEL-$ARCH/bin/aarch64-linux-android21-clang"; then + echo "arm64 host is not supported by $ANDROID_NDK_ROOT; trying to use x86_64, in case the host has a binary translation such as Rosetta or QEMU installed." + echo "(Newer NDK may support arm64 host but may lack support for Android4/ABI16)" + ARCH="x86_64" + fi + + NDK_HOST_TAG="$KERNEL-$ARCH" +fi + +if test -z "$CARGO_TARGET_DIR"; then + export CARGO_TARGET_DIR=/tmp/arcanechat-build +fi + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/$NDK_HOST_TAG" +export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$TOOLCHAIN/bin/armv7a-linux-androideabi21-clang" +export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/aarch64-linux-android21-clang" +export CARGO_TARGET_I686_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/i686-linux-android21-clang" +export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/x86_64-linux-android21-clang" + +export RUSTUP_TOOLCHAIN=$(cat "$(dirname "$0")/rust-toolchain") + +if test "$1" = "--debug"; then + echo Quick debug build that will produce a slower app. DO NOT UPLOAD THE APK ANYWHERE. + + RELEASE="debug" + RELEASEFLAG="" + + shift +else + echo Full build + + # According to 1.45.0 changelog in https://github.com/rust-lang/rust/blob/master/RELEASES.md, + # "The recommended way to control LTO is with Cargo profiles, either in Cargo.toml or .cargo/config, or by setting CARGO_PROFILE__LTO in the environment." + export CARGO_PROFILE_RELEASE_LTO=on + RELEASE="release" + RELEASEFLAG="--release" +fi + +# Check if the argument is a correct architecture: +if test $1 && echo "armeabi-v7a arm64-v8a x86 x86_64" | grep -vwq -- $1; then + echo "Architecture '$1' not known, possible values are armeabi-v7a, arm64-v8a, x86 and x86_64." + exit +fi + +cd jni +jnidir=$PWD +rm -f armeabi-v7a/* +rm -f arm64-v8a/* +rm -f x86/* +rm -f x86_64/* +mkdir -p armeabi-v7a +mkdir -p arm64-v8a +mkdir -p x86 +mkdir -p x86_64 + +cd deltachat-core-rust + +# fix build on MacOS Catalina +unset CPATH + +if test -z $1 || test $1 = armeabi-v7a; then + echo "-- cross compiling to armv7-linux-androideabi (arm) --" + TARGET_CC="$TOOLCHAIN/bin/armv7a-linux-androideabi21-clang" \ + TARGET_AR="$TOOLCHAIN/bin/llvm-ar" \ + TARGET_RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + cargo build $RELEASEFLAG --target armv7-linux-androideabi -p deltachat_ffi + cp "$CARGO_TARGET_DIR/armv7-linux-androideabi/$RELEASE/libdeltachat.a" "$jnidir/armeabi-v7a" +fi + +if test -z $1 || test $1 = arm64-v8a; then + echo "-- cross compiling to aarch64-linux-android (arm64) --" + TARGET_CC="$TOOLCHAIN/bin/aarch64-linux-android21-clang" \ + TARGET_AR="$TOOLCHAIN/bin/llvm-ar" \ + TARGET_RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + cargo build $RELEASEFLAG --target aarch64-linux-android -p deltachat_ffi + cp "$CARGO_TARGET_DIR/aarch64-linux-android/$RELEASE/libdeltachat.a" "$jnidir/arm64-v8a" +fi + +if test -z $1 || test $1 = x86; then + echo "-- cross compiling to i686-linux-android (x86) --" + TARGET_CC="$TOOLCHAIN/bin/i686-linux-android21-clang" \ + TARGET_AR="$TOOLCHAIN/bin/llvm-ar" \ + TARGET_RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + cargo build $RELEASEFLAG --target i686-linux-android -p deltachat_ffi + cp "$CARGO_TARGET_DIR/i686-linux-android/$RELEASE/libdeltachat.a" "$jnidir/x86" +fi + +if test -z $1 || test $1 = x86_64; then + echo "-- cross compiling to x86_64-linux-android (x86_64) --" + TARGET_CC="$TOOLCHAIN/bin/x86_64-linux-android21-clang" \ + TARGET_AR="$TOOLCHAIN/bin/llvm-ar" \ + TARGET_RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + cargo build $RELEASEFLAG --target x86_64-linux-android -p deltachat_ffi + cp "$CARGO_TARGET_DIR/x86_64-linux-android/$RELEASE/libdeltachat.a" "$jnidir/x86_64" +fi + +echo -- ndk-build -- + +cd ../.. + +if test $1; then + "$ANDROID_NDK_ROOT/ndk-build" APP_ABI="$1" +else + # We are compiling for all architectures defined in Application.mk + "$ANDROID_NDK_ROOT/ndk-build" +fi + +if test $1; then + echo "NDK_ARCH=$1" >ndkArch +else + rm -f ndkArch # Remove ndkArch, ignore if it doesn't exist +fi + +"$(dirname "$0")/rebrand.sh" + + +echo "ending time: `date`" diff --git a/scripts/rebrand.sh b/scripts/rebrand.sh new file mode 100644 index 0000000000000000000000000000000000000000..714eb2cb2afb9d257e3e8a1012fdc599ac7e92cc --- /dev/null +++ b/scripts/rebrand.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +find ./src/main/assets/help/ -type f -name '*.html' | xargs sed -i 's/get.delta.chat/github.com\/ArcaneChat/g' +find ./src/main/assets/help/ -type f -name '*.html' | xargs sed -i 's/Delta Chat/ArcaneChat/g' + +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/get.delta.chat/github.com\/ArcaneChat/g' +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/delta.chat\/donate/arcanechat.me\/#contribute/g' +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/Delta Chat/ArcaneChat/g' diff --git a/scripts/rust-toolchain b/scripts/rust-toolchain new file mode 100644 index 0000000000000000000000000000000000000000..8d3947429fa55cc2b69fbd1ce00edf35611cee43 --- /dev/null +++ b/scripts/rust-toolchain @@ -0,0 +1 @@ +1.91.1 diff --git a/scripts/tx-pull-source.sh b/scripts/tx-pull-source.sh new file mode 100644 index 0000000000000000000000000000000000000000..6bf99aa16f4837944f3ee96b008f1d20d087b0f0 --- /dev/null +++ b/scripts/tx-pull-source.sh @@ -0,0 +1,5 @@ +RES=src/main/res +tx pull -l en +mv $RES/values-en/strings.xml $RES/values/strings.xml +rmdir $RES/values-en +./scripts/check-translations.sh diff --git a/scripts/tx-pull-translations.sh b/scripts/tx-pull-translations.sh new file mode 100644 index 0000000000000000000000000000000000000000..f2d1b02144210b8bb402aa407be438a451af3733 --- /dev/null +++ b/scripts/tx-pull-translations.sh @@ -0,0 +1,2 @@ +tx pull -f +./scripts/check-translations.sh diff --git a/scripts/tx-push-source.sh b/scripts/tx-push-source.sh new file mode 100644 index 0000000000000000000000000000000000000000..10fe83bc27af72f7964159d605522ade81d57353 --- /dev/null +++ b/scripts/tx-push-source.sh @@ -0,0 +1,3 @@ +read -p "Push src/main/res/values/strings.xml to transifex? Press ENTER to continue, CTRL-C to abort." +tx push -s +./scripts/check-translations.sh diff --git a/scripts/tx-update-changed-sources.sh b/scripts/tx-update-changed-sources.sh new file mode 100644 index 0000000000000000000000000000000000000000..174cfe07b382ba19ecc58c53cbbdc464270bd107 --- /dev/null +++ b/scripts/tx-update-changed-sources.sh @@ -0,0 +1,13 @@ +echo "This script allows to make changes to the english source" +echo "without forcing all translations to be redone." +echo "(on transifex the english source is the key)" +echo "This is done by pulling the translations" +echo "and immediately pushing them again together with the source." +echo "************************************************************************************" +echo "Pushing translations is POTENTIALLY HARMFUL so this script should be used with care." +echo "In most cases, just use ./scripts/tx-push-translations.sh which is safer." +echo "************************************************************************************" +read -p "Press ENTER to continue, CTRL-C to abort." +tx pull -f +tx push -s -t +./scripts/check-translations.sh diff --git a/scripts/undo_rebrand.sh b/scripts/undo_rebrand.sh new file mode 100644 index 0000000000000000000000000000000000000000..c8367143c3688cad525ba016c96ff716ff128704 --- /dev/null +++ b/scripts/undo_rebrand.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +find ./src/main/assets/help/ -type f -name '*.html' | xargs sed -i 's/github.com\/ArcaneChat/get.delta.chat/g' +find ./src/main/assets/help/ -type f -name '*.html' | xargs sed -i 's/ArcaneChat/Delta Chat/g' + +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/github.com\/ArcaneChat/get.delta.chat/g' +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/arcanechat.me\/#contribute/delta.chat\/donate/g' +find ./src/ -type f -name 'strings.xml' | xargs sed -i 's/ArcaneChat/Delta Chat/g' + +# don't revert the app name +sed -i 's/>Delta ChatArcaneChat "$ROOT_DIR/schema.json" +cd "$ROOT_DIR" + +# generate code +dcrpcgen java --schema schema.json -o ./src/main/java/ diff --git a/scripts/upload-beta.sh b/scripts/upload-beta.sh new file mode 100644 index 0000000000000000000000000000000000000000..74bf8d5eaaa0dd8098b4a25f1ee3f7e60720d483 --- /dev/null +++ b/scripts/upload-beta.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +VERSION=$1 + +if [ -z "$VERSION" ]; then + echo "this script uploads test apks to download.delta.chat/android/beta, both flavours:" + echo "- 🍋 gplay (overwrites gplay installs)" + echo "- 🍉 dev (can be installed beside gplay)" + echo "" + echo "usage: ./scripts/upload-beta.sh " + exit +fi +if [[ ${VERSION:0:1} == "v" ]]; then + echo "VERSION must not begin with 'v' here." + exit +fi + +APKGPLAY="gplay/release/deltachat-gplay-release-$VERSION.apk" +APKDEV="build/outputs/apk/foss/debug/deltachat-foss-debug-$VERSION.apk" +ls -l $APKGPLAY +ls -l $APKDEV +read -p "upload these apks to download.delta.chat/android/beta? ENTER to continue, CTRL-C to abort." + +# see docs/upload-release for some hints wrt keys +rsync --progress $APKGPLAY jekyll@download.delta.chat:/var/www/html/download/android/beta/ +rsync --progress $APKDEV jekyll@download.delta.chat:/var/www/html/download/android/beta/ + +echo "upload done." +echo "" +echo "and now: here is Delta Chat $VERSION - choose your flavour and mind your backups:" +echo "- 🍋 https://download.delta.chat/android/beta/deltachat-gplay-release-$VERSION.apk (google play candidate, overwrites existing installs, should keep data)" +echo "- 🍉 https://download.delta.chat/android/beta/deltachat-foss-debug-$VERSION.apk (f-droid candidate, can be installed beside google play)" +echo "" +echo "what to test: PLEASE_FILL_OUT" + + diff --git a/scripts/upload-release.sh b/scripts/upload-release.sh new file mode 100644 index 0000000000000000000000000000000000000000..7161feb8b87f25b2fb0223cda1256d9e190d6a4f --- /dev/null +++ b/scripts/upload-release.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +VERSION=$1 + +if [ -z "$VERSION" ]; then + echo "this script uploads release-ready apk and symbols to download.delta.chat/android" + echo "- for showing up on get.delta.chat" + echo " you still need to change deltachat-pages/_includes/download-boxes.html" + echo "- the script does not upload things to gplay or other stores." + echo "" + echo "usage: ./scripts/upload-release.sh " + exit +fi +if [[ ${VERSION:0:1} == "v" ]]; then + echo "VERSION must not begin with 'v' here." + exit +fi + +cd gplay/release +APK="deltachat-gplay-release-$VERSION.apk" +ls -l $APK +read -p "upload this apk and belonging symbols to download.delta.chat/android? ENTER to continue, CTRL-C to abort." + +# you need the private SSH key of the jekyll user; you can find it in this file: +# https://github.com/hpk42/otf-deltachat/blob/master/secrets/delta.chat +# It is protected with [git-crypt](https://www.agwa.name/projects/git-crypt/) - +# after installing it, you can decrypt it with `git crypt unlock`. +# If your key isn't added to the secrets, and you know some of the team in person, +# you can ask on irc #deltachat for access. +# Add the key to your `~/.ssh/config` for the host, or to your ssh-agent, so rsync is able to use it) +rsync --progress $APK jekyll@download.delta.chat:/var/www/html/download/android/ + +cd ../.. +SYMBOLS_ZIP="$APK-symbols.zip" +rm $SYMBOLS_ZIP +zip -r $SYMBOLS_ZIP obj +ls -l $SYMBOLS_ZIP +rsync --progress $SYMBOLS_ZIP jekyll@download.delta.chat:/var/www/html/download/android/symbols/ + +echo "upload done." +echo "" +echo "and now: here is Delta Chat $VERSION:" +echo "- 🍋 https://download.delta.chat/android/deltachat-gplay-release-$VERSION.apk (google play candidate, overwrites existing installs, should keep data)" +echo "" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000000000000000000000000000000000000..8aeca1ea1c3e364bed869e390c0cacedff92b590 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + google() + mavenCentral() + } +} diff --git a/src/androidTest/java/com/b44t/messenger/TestUtils.java b/src/androidTest/java/com/b44t/messenger/TestUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..1953ee179bd7072573daa8f98029b97d396262e6 --- /dev/null +++ b/src/androidTest/java/com/b44t/messenger/TestUtils.java @@ -0,0 +1,182 @@ +package com.b44t.messenger; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.util.TreeIterables; +import androidx.test.ext.junit.rules.ActivityScenarioRule; + +import org.hamcrest.Matcher; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.AccessibilityUtil; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +public class TestUtils { + private static int createdAccountId = 0; + private static boolean resetEnterSends = false; + + public static void cleanupCreatedAccount(Context context) { + DcAccounts accounts = DcHelper.getAccounts(context); + if (createdAccountId != 0) { + accounts.removeAccount(createdAccountId); + createdAccountId = 0; + } + } + + public static void cleanup() { + Context context = getInstrumentation().getTargetContext(); + cleanupCreatedAccount(context); + if (resetEnterSends) { + Prefs.setEnterSendsEnabled(getInstrumentation().getTargetContext(), false); + } + } + + public static void createOfflineAccount() { + Context context = getInstrumentation().getTargetContext(); + cleanupCreatedAccount(context); + createdAccountId = AccountManager.getInstance().beginAccountCreation(context); + DcContext c = DcHelper.getContext(context); + c.setConfig("configured_addr", "alice@example.org"); + c.setConfig("configured_mail_pw", "abcd"); + c.setConfig("configured", "1"); + } + + @NonNull + public static ActivityScenarioRule getOfflineActivityRule(boolean useExistingChats) { + Intent intent = + Intent.makeMainActivity( + new ComponentName(getInstrumentation().getTargetContext(), ConversationListActivity.class)); + if (!useExistingChats) { + createOfflineAccount(); + } + prepare(); + return new ActivityScenarioRule<>(intent); + } + + @NonNull + public static ActivityScenarioRule getOnlineActivityRule(Class activityClass) { + Context context = getInstrumentation().getTargetContext(); + AccountManager.getInstance().beginAccountCreation(context); + prepare(); + return new ActivityScenarioRule<>(new Intent(getInstrumentation().getTargetContext(), activityClass)); + } + + private static void prepare() { + Prefs.setBooleanPreference(getInstrumentation().getTargetContext(), Prefs.DOZE_ASKED_DIRECTLY, true); + if (!AccessibilityUtil.areAnimationsDisabled(getInstrumentation().getTargetContext())) { + throw new RuntimeException("To run the tests, disable animations at Developer options' " + + "-> 'Window/Transition/Animator animation scale' -> Set all 3 to 'off'"); + } + } + + /** + * Perform action of waiting for a certain view within a single root view + * + * @param matcher Generic Matcher used to find our view + */ + private static ViewAction searchFor(Matcher matcher) { + return new ViewAction() { + + public Matcher getConstraints() { + return isRoot(); + } + + public String getDescription() { + return "searching for view $matcher in the root view"; + } + + public void perform(UiController uiController, View view) { + + Iterable childViews = TreeIterables.breadthFirstViewTraversal(view); + + // Look for the match in the tree of childviews + for (View it : childViews) { + if (matcher.matches(it)) { + // found the view + return; + } + } + + throw new NoMatchingViewException.Builder() + .withRootView(view) + .withViewMatcher(matcher) + .build(); + } + }; + } + + /** + * Perform action of implicitly waiting for a certain view. + * This differs from EspressoExtensions.searchFor in that, + * upon failure to locate an element, it will fetch a new root view + * in which to traverse searching for our @param match + * + * @param viewMatcher ViewMatcher used to find our view + */ + public static ViewInteraction waitForView( + Matcher viewMatcher, + int waitMillis, + int waitMillisPerTry + ) { + + // Derive the max tries + int maxTries = (int) (waitMillis / waitMillisPerTry); + + int tries = 0; + + for (int i = 0; i < maxTries; i++) + try { + // Track the amount of times we've tried + tries++; + + // Search the root for the view + onView(isRoot()).perform(searchFor(viewMatcher)); + + // If we're here, we found our view. Now return it + return onView(viewMatcher); + + } catch (Exception e) { + if (tries == maxTries) { + throw e; + } + Util.sleep(waitMillisPerTry); + } + + throw new RuntimeException("Error finding a view matching $viewMatcher"); + } + + /** + * Normally, you would do + * onView(withId(R.id.send_button)).perform(click()); + * to send the draft message. However, in order to change the send button to the attach button + * while there is no draft, the send button is made invisible and the attach button is made + * visible instead. This confuses the test framework.

+ * + * So, this is a workaround for pressing the send button. + */ + public static void pressSend() { + if (!Prefs.isEnterSendsEnabled(getInstrumentation().getTargetContext())) { + resetEnterSends = true; + Prefs.setEnterSendsEnabled(getInstrumentation().getTargetContext(), true); + } + waitForView(withHint(R.string.chat_input_placeholder), 10000, 100).perform(typeText("\n")); + } +} diff --git a/src/androidTest/java/com/b44t/messenger/uibenchmarks/EnterChatsBenchmark.java b/src/androidTest/java/com/b44t/messenger/uibenchmarks/EnterChatsBenchmark.java new file mode 100644 index 0000000000000000000000000000000000000000..645585b5afbd1b447f6a922ed396f52798d4e7e9 --- /dev/null +++ b/src/androidTest/java/com/b44t/messenger/uibenchmarks/EnterChatsBenchmark.java @@ -0,0 +1,154 @@ +package com.b44t.messenger.uibenchmarks; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import android.util.Log; + +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import com.b44t.messenger.TestUtils; + +import org.junit.After; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; + +@Ignore("This is not a test, but a benchmark. Remove the @Ignore to run it.") +@RunWith(AndroidJUnit4.class) +@LargeTest +public class EnterChatsBenchmark { + + // ============================================================================================== + // Set this to true if you already have at least 10 chats on your existing DeltaChat installation + // and want to traverse through them instead of 10 newly created chats + private final static boolean USE_EXISTING_CHATS = false; + // ============================================================================================== + private final static int GO_THROUGH_ALL_CHATS_N_TIMES = 8; + + // ============================================================================================== + // PLEASE BACKUP YOUR ACCOUNT BEFORE RUNNING THIS! + // ============================================================================================== + + private final static String TAG = EnterChatsBenchmark.class.getSimpleName(); + + @Rule + public ActivityScenarioRule activityRule = TestUtils.getOfflineActivityRule(USE_EXISTING_CHATS); + + @Test + public void createAndEnter10FilledChats() { + create10Chats(true); + + String[] times = new String[GO_THROUGH_ALL_CHATS_N_TIMES]; + for (int i = 0; i < GO_THROUGH_ALL_CHATS_N_TIMES; i++) { + times[i] = "" + timeGoToNChats(10); // 10 group chats were created + } + Log.i(TAG, "MEASURED RESULTS (Benchmark) - Going thorough all 10 chats: " + String.join(",", times)); + } + + @Test + public void createAndEnterEmptyChats() { + create10Chats(false); + + String[] times = new String[GO_THROUGH_ALL_CHATS_N_TIMES]; + for (int i = 0; i < GO_THROUGH_ALL_CHATS_N_TIMES; i++) { + times[i] = "" + timeGoToNChats(1); + } + Log.i(TAG, "MEASURED RESULTS (Benchmark) - Entering and leaving 1 empty chat: " + String.join(",", times)); + } + + @Test + public void enterFilledChat() { + if (!USE_EXISTING_CHATS) { + createChatAndGoBack("Group #1", true, "Hello!", "Some links: https://testrun.org", "And a command: /help"); + } + + String[] times = new String[50]; + for (int i = 0; i < times.length; i++) { + long start = System.currentTimeMillis(); + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click())); + long end = System.currentTimeMillis(); + long diff = end - start; + pressBack(); + Log.i(TAG, "Measured (Benchmark) " + (i+1) + "/" + times.length + ": Entering 1 filled chat took " + diff + "ms " + "(going back took " + (System.currentTimeMillis() - end) + "ms)"); + + times[i] = "" + diff; + } + Log.i(TAG, "MEASURED RESULTS (Benchmark) - Entering 1 filled chat: " + String.join(",", times)); + } + + private void create10Chats(boolean fillWithMsgs) { + if (!USE_EXISTING_CHATS) { + createChatAndGoBack("Group #1", fillWithMsgs, "Hello!", "Some links: https://testrun.org", "And a command: /help"); + createChatAndGoBack("Group #2", fillWithMsgs, "example.org, alice@example.org", "aaaaaaa", "bbbbbb"); + createChatAndGoBack("Group #3", fillWithMsgs, repeat("Some string ", 600), repeat("Another string", 200), "Hi!!!"); + createChatAndGoBack("Group #4", fillWithMsgs, "xyzabc", "Hi!!!!", "Let's meet!"); + createChatAndGoBack("Group #5", fillWithMsgs, repeat("aaaa", 40), "bbbbbbbbbbbbbbbbbb", "ccccccccccccccc"); + createChatAndGoBack("Group #6", fillWithMsgs, "aaaaaaaaaaa", repeat("Hi! ", 1000), "bbbbbbbbbb"); + createChatAndGoBack("Group #7", fillWithMsgs, repeat("abcdefg ", 500), repeat("xxxxx", 100), "yrrrrrrrrrrrrr"); + createChatAndGoBack("Group #8", fillWithMsgs, "and a number: 037362/384756", "ccccc", "Nice!"); + createChatAndGoBack("Group #9", fillWithMsgs, "ddddddddddddddddd", "zuuuuuuuuuuuuuuuu", "ccccc"); + createChatAndGoBack("Group #10", fillWithMsgs, repeat("xxxxxxyyyyy", 100), repeat("String!!", 10), "abcd"); + } + } + + private long timeGoToNChats(int numChats) { + long start = System.currentTimeMillis(); + for (int i = 0; i < numChats; i++) { + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(i, click())); + pressBack(); + } + long diff = System.currentTimeMillis() - start; + Log.i(TAG, "Measured (Benchmark): Going through " + numChats + " chats took " + diff + "ms"); + return diff; + } + + private String repeat(String string, int n) { + StringBuilder s = new StringBuilder(); + for (int i = 0; i < n; i++) { + s.append(string); + } + return s.toString(); + } + + private void createChatAndGoBack(String groupName, boolean fillWithMsgs, String... texts) { + onView(withId(R.id.fab)).perform(click()); + onView(withText(R.string.menu_new_group)).perform(click()); + onView(withHint(R.string.name_desktop)).perform(replaceText(groupName)); + onView(withContentDescription(R.string.group_create_button)).perform(click()); + + if (fillWithMsgs) { + for (String t: texts) { + sendText(t); + } + for (String t: texts) { + sendText(t); + } + } + + pressBack(); + pressBack(); + } + + private void sendText(String text1) { + onView(withHint(R.string.chat_input_placeholder)).perform(replaceText(text1)); + TestUtils.pressSend(); + } + + @After + public void cleanup() { + TestUtils.cleanup(); + } +} diff --git a/src/androidTest/java/com/b44t/messenger/uitests/offline/ForwardingTest.java b/src/androidTest/java/com/b44t/messenger/uitests/offline/ForwardingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5c6acacb17e892e388b42768bf1c370e85b38641 --- /dev/null +++ b/src/androidTest/java/com/b44t/messenger/uitests/offline/ForwardingTest.java @@ -0,0 +1,94 @@ +package com.b44t.messenger.uitests.offline; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import androidx.test.espresso.IdlingPolicies; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.TestUtils; + +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; + +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class ForwardingTest { + private static int createdGroupId; + + @BeforeClass + public static void beforeClass() { + IdlingPolicies.setMasterPolicyTimeout(10, TimeUnit.SECONDS); + IdlingPolicies.setIdlingResourceTimeout(10, TimeUnit.SECONDS); + } + + @Rule + public final ActivityScenarioRule activityRule = TestUtils.getOfflineActivityRule(false); + + @Before + public void createChats() { + DcContext dcContext = DcHelper.getContext(getInstrumentation().getTargetContext()); + dcContext.createChatByContactId(DcContact.DC_CONTACT_ID_SELF); + // Disable bcc_self so that DC doesn't try to send messages to the server. + // If we didn't do this, messages would stay in DC_STATE_OUT_PENDING forever. + // The thing is, DC_STATE_OUT_PENDING show a rotating circle animation, and Espresso doesn't work + // with animations, and the tests would hang and never finish. + dcContext.setConfig("bcc_self", "0"); + activityRule.getScenario().onActivity(a -> createdGroupId = DcHelper.getContext(a).createGroupChat( "group")); + } + + @After + public void cleanup() { + TestUtils.cleanup(); + } + + @Test + public void testSimpleForwarding() { + // Open device talk + // The group is at position 0, self chat is at position 1, device talk is at position 2 + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(2, click())); + onView(withId(R.id.title)).check(matches(withText(R.string.device_talk))); + onView(withId(android.R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())); + onView(withId(R.id.menu_context_forward)).perform(click()); + // Send it to self chat (which is sorted to the top because we're forwarding) + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click())); + + onView(withId(R.id.title)).check(matches(withText(R.string.device_talk))); + + pressBack(); + + onView(withId(R.id.toolbar_title)).check(matches(withText(R.string.connectivity_not_connected))); + // Self chat moved up because we sent a message there + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click())); + onView(withId(R.id.title)).check(matches(withText(R.string.saved_messages))); + onView(withId(android.R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())); + onView(withId(R.id.menu_context_forward)).perform(click()); + // Send it to the group + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(1, click())); + onView(withText(android.R.string.ok)).perform(click()); + onView(withId(R.id.title)).check(matches(withText("group"))); + + pressBack(); + onView(withId(R.id.toolbar_title)).check(matches(withText(R.string.connectivity_not_connected))); + } +} diff --git a/src/androidTest/java/com/b44t/messenger/uitests/offline/SharingTest.java b/src/androidTest/java/com/b44t/messenger/uitests/offline/SharingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..02ada950c191c4dec281bb16ef2744224474a9d7 --- /dev/null +++ b/src/androidTest/java/com/b44t/messenger/uitests/offline/SharingTest.java @@ -0,0 +1,231 @@ +package com.b44t.messenger.uitests.offline; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isClickable; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import android.content.ComponentName; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.TestUtils; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.ShareActivity; +import org.thoughtcrime.securesms.connect.DcHelper; + +import java.io.File; + + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class SharingTest { + // ============================================================================================== + // PLEASE BACKUP YOUR ACCOUNT BEFORE RUNNING THIS! + // ============================================================================================== + + private static int createdGroupId; + private static int createdSingleChatId; + + @Rule + public final ActivityScenarioRule activityRule = TestUtils.getOfflineActivityRule(false); + + @Before + public void createGroup() { + activityRule.getScenario().onActivity(a -> createdGroupId = DcHelper.getContext(a).createGroupChat( "group")); + } + + @Before + public void createSingleChat() { + activityRule.getScenario().onActivity(a -> { + int contactId = DcHelper.getContext(a).createContact("", "abc@example.org"); + createdSingleChatId = DcHelper.getContext(a).createChatByContactId(contactId); + }); + } + + @Test + public void testNormalSharing() { + Intent i = new Intent(Intent.ACTION_SEND); + i.putExtra(Intent.EXTRA_TEXT, "Hello!"); + i.setComponent(new ComponentName(getInstrumentation().getTargetContext().getApplicationContext(), ShareActivity.class)); + activityRule.getScenario().onActivity(a -> a.startActivity(i)); + + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click())); + + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("Hello!"))); + TestUtils.pressSend(); + } + + /** + * Test direct sharing from a screenshot. + * Also, this is the regression test for https://github.com/deltachat/deltachat-android/issues/2040 + * where network changes during sharing lead to a bug + */ + @Test + public void testShareFromScreenshot() { + DcContext dcContext = DcHelper.getContext(getInstrumentation().getTargetContext()); + String[] files = new File(dcContext.getBlobdir()).list(); + String pngImage = null; + assert files != null; + for (String file : files) { + if (file.endsWith(".png")) { + pngImage = file; + } + } + Uri uri = Uri.parse("content://" + BuildConfig.APPLICATION_ID + ".attachments/" + Uri.encode(pngImage)); + DcHelper.sharedFiles.put(pngImage, "image/png"); + + Intent i = new Intent(Intent.ACTION_SEND); + i.setType("image/png"); + i.putExtra(Intent.EXTRA_SUBJECT, "Screenshot (Sep 27, 2021 00:00:00"); + i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET + | Intent.FLAG_ACTIVITY_FORWARD_RESULT + | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP + | Intent.FLAG_RECEIVER_FOREGROUND + | Intent.FLAG_GRANT_READ_URI_PERMISSION); + i.putExtra(Intent.EXTRA_STREAM, uri); + i.putExtra(ShareActivity.EXTRA_CHAT_ID, createdGroupId); + i.setComponent(new ComponentName(getInstrumentation().getTargetContext().getApplicationContext(), ShareActivity.class)); + activityRule.getScenario().onActivity(a -> a.startActivity(i)); + + TestUtils.waitForView(withId(R.id.send_button), 10000, 50); + + dcContext.maybeNetwork(); + dcContext.maybeNetwork(); + dcContext.maybeNetwork(); + + onView(withId(R.id.send_button)).perform(click()); + pressBack(); + + onView(withId(R.id.fab)).check(matches(isClickable())); + } + + /** + * Tests https://github.com/deltachat/interface/blob/master/user-testing/mailto-links.md#mailto-links: + * + *
    + *
  • Just an email address - should open a chat with abc@example.org (and maybe ask whether a chat should be created if it does not exist already)
  • + *
  • email address with subject - should open a chat with abc@example.org and fill testing mailto uris; as we created the chat in the previous step, it should not ask Chat with … but directly open the chat
  • + *
  • email address with body - should open a chat with abc@example.org, draft this is a test
  • + *
  • email address with subject and body - should open a chat with abc@example.org, draft testing mailto uris <newline> this is a test
  • + *
  • HTML encoding - should open a chat with info@example.org
  • + *
  • more HTML encoding - should open a chat with simplebot@example.org, draft !web https://duckduckgo.com/lite?q=duck%20it
  • + *
  • no email, just subject&body - this should let you choose a chat and create a draft bla <newline> blub there
  • + *
+ */ + @Test + public void testShareFromLink() { + openLink("mailto:abc@example.org"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + + openLink("mailto:abc@example.org?subject=testing%20mailto%20uris"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("testing mailto uris"))); + + openLink("mailto:abc@example.org?body=this%20is%20a%20test"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("this is a test"))); + + openLink("mailto:abc@example.org?subject=testing%20mailto%20uris&body=this%20is%20a%20test"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("testing mailto uris\nthis is a test"))); + + openLink("mailto:%20abc@example.org"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + + openLink("mailto:abc@example.org?body=!web%20https%3A%2F%2Fduckduckgo.com%2Flite%3Fq%3Dduck%2520it"); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("!web https://duckduckgo.com/lite?q=duck%20it"))); + + openLink("mailto:?subject=bla&body=blub"); + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText("abc@example.org")), click())); + onView(withId(R.id.subtitle)).check(matches(withText("abc@example.org"))); + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("bla\nblub"))); + } + + private void openLink(String link) { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(link)); + i.setPackage(getInstrumentation().getTargetContext().getPackageName()); + activityRule.getScenario().onActivity(a -> a.startActivity(i)); + } + + /** + *
    + *
  • Open Saved Messages chat (could be any other chat too)
  • + *
  • Go to another app and share some text to DC
  • + *
  • In DC select Saved Messages. Edit the shared text if you like. Don't hit the Send button.
  • + *
  • Leave DC
  • + *
  • Open DC again from the "Recent apps"
  • + *
  • Check that your draft is still there
  • + *
+ */ + @Test + public void testOpenAgainFromRecents() { + // Open a chat + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText("abc@example.org")), click())); + + // Share some text to DC + Intent i = new Intent(Intent.ACTION_SEND); + i.putExtra(Intent.EXTRA_TEXT, "Veeery important draft"); + i.setComponent(new ComponentName(getInstrumentation().getTargetContext().getApplicationContext(), ShareActivity.class)); + activityRule.getScenario().onActivity(a -> a.startActivity(i)); + + // In DC, select the same chat you opened before + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText("abc@example.org")), click())); + + // Leave DC and go back to the previous activity + pressBack(); + + // Here, we can't exactly replicate the "steps to reproduce". Previously, the other activity + // stayed open in the background, but since it doesn't anymore, we need to open it again: + onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText("abc@example.org")), click())); + + // Check that the draft is still there + // Util.sleep(2000); // Uncomment for debugging + onView(withHint(R.string.chat_input_placeholder)).check(matches(withText("Veeery important draft"))); + } + + /** + * Regression test: + * + * If you save your contacts's emails in the contacts app of the phone, there are buttons to call + * them and also to write an email to them. + * + * If you click the email button, ArcaneChat opened but instead of opening a chat with that contact, + * the chat list was show and "share with" was displayed at the top + */ + @Test + public void testOpenChatFromContacts() { + Intent i = new Intent(Intent.ACTION_SENDTO); + i.setData(Uri.parse("mailto:bob%40example.org")); + i.setPackage(getInstrumentation().getTargetContext().getPackageName()); + activityRule.getScenario().onActivity(a -> a.startActivity(i)); + + onView(withId(R.id.subtitle)).check(matches(withText("bob@example.org"))); + } + + @After + public void cleanup() { + TestUtils.cleanup(); + } +} diff --git a/src/androidTest/java/com/b44t/messenger/uitests/online/OnboardingTest.java b/src/androidTest/java/com/b44t/messenger/uitests/online/OnboardingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..880de597edbe240c70ae644f272e35a9a4afd220 --- /dev/null +++ b/src/androidTest/java/com/b44t/messenger/uitests/online/OnboardingTest.java @@ -0,0 +1,58 @@ +package com.b44t.messenger.uitests.online; + + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isClickable; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import android.text.TextUtils; + +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import com.b44t.messenger.TestUtils; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WelcomeActivity; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class OnboardingTest { + @Rule + public ActivityScenarioRule activityRule = TestUtils.getOnlineActivityRule(WelcomeActivity.class); + + @Test + public void testAccountCreation() { + if (TextUtils.isEmpty(BuildConfig.TEST_ADDR) || TextUtils.isEmpty(BuildConfig.TEST_MAIL_PW)) { + throw new RuntimeException("You need to set TEST_ADDR and TEST_MAIL_PW; " + + "either in gradle.properties or via an environment variable. " + + "See README.md for more details."); + } + onView(withText(R.string.scan_invitation_code)).check(matches(isClickable())); + onView(withText(R.string.import_backup_title)).check(matches(isClickable())); + onView(withText(R.string.manual_account_setup_option)).perform(click()); + onView(withHint(R.string.email_address)).perform(replaceText(BuildConfig.TEST_ADDR)); + onView(withHint(R.string.existing_password)).perform(replaceText(BuildConfig.TEST_MAIL_PW)); + onView(withContentDescription(R.string.ok)).perform(click()); + TestUtils.waitForView(withText(R.string.app_name), 10000, 100); + + // TODO: Try to also perform other steps of the release checklist at + // https://github.com/deltachat/deltachat-android/blob/master/docs/release-checklist.md#testing-checklist + } + + @After + public void cleanup() { + TestUtils.cleanup(); + } +} diff --git a/src/foss/AndroidManifest.xml b/src/foss/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..498c668a41c9b6623bc0478b608cba41bc42dfd5 --- /dev/null +++ b/src/foss/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/foss/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java b/src/foss/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java new file mode 100644 index 0000000000000000000000000000000000000000..dfb2f61c1e52d6a4e17a9683bb3cc8247319ffa1 --- /dev/null +++ b/src/foss/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.Context; + +import androidx.annotation.Nullable; + +/* + Fake do-nothing implementation of FcmReceiveService. + The real implementation is in the gplay flavor only. +*/ +public class FcmReceiveService { + public static void register(Context context) {} + public static void waitForRegisterFinished() {} + @Nullable public static String getToken() { return null; } +} diff --git a/src/gplay/AndroidManifest.xml b/src/gplay/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..b0d786db4f95ea4b94645a6159d9edb88edbf7c9 --- /dev/null +++ b/src/gplay/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/gplay/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java b/src/gplay/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java new file mode 100644 index 0000000000000000000000000000000000000000..9affbc1ed8bc42d22e68efd0f77b153672801ce8 --- /dev/null +++ b/src/gplay/java/org/thoughtcrime/securesms/notifications/FcmReceiveService.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.android.gms.tasks.Tasks; +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.service.FetchForegroundService; +import org.thoughtcrime.securesms.util.Util; + +public class FcmReceiveService extends FirebaseMessagingService { + private static final String TAG = FcmReceiveService.class.getSimpleName(); + private static final Object INIT_LOCK = new Object(); + private static boolean initialized; + private static volatile boolean triedRegistering; + private static volatile String prefixedToken; + + public static void register(Context context) { + + if (FcmReceiveService.prefixedToken != null) { + Log.i(TAG, "FCM already registered"); + triedRegistering = true; + return; + } + + Util.runOnAnyBackgroundThread(() -> { + final String rawToken; + + try { + synchronized (INIT_LOCK) { + if (!initialized) { + // manual init: read tokens from `./google-services.json`; + // automatic init disabled in AndroidManifest.xml to skip FCM code completely. + FirebaseApp.initializeApp(context); + } + initialized = true; + } + rawToken = Tasks.await(FirebaseMessaging.getInstance().getToken()); + } catch (Exception e) { + // we're here usually when FCM is not available and initializeApp() or getToken() failed. + Log.w(TAG, "cannot get FCM token for " + BuildConfig.APPLICATION_ID + ": " + e); + triedRegistering = true; + return; + } + if (TextUtils.isEmpty(rawToken)) { + Log.w(TAG, "got empty FCM token for " + BuildConfig.APPLICATION_ID); + triedRegistering = true; + return; + } + + prefixedToken = addPrefix(rawToken); + Log.i(TAG, "FCM token: " + prefixedToken); + ApplicationContext.dcAccounts.setPushDeviceToken(prefixedToken); + triedRegistering = true; + }); + } + + // wait a until FCM registration got a token or not. + // we're calling register() pretty soon and getToken() pretty late on init, + // so usually, this should not block anything. + // still, waitForRegisterFinished() needs to be called from a background thread. + @WorkerThread + public static void waitForRegisterFinished() { + while (!triedRegistering) { + Util.sleep(100); + } + } + + private static String addPrefix(String rawToken) { + return "fcm-" + BuildConfig.APPLICATION_ID + ":" + rawToken; + } + + @Nullable + public static String getToken() { + return prefixedToken; + } + + @WorkerThread + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + Log.i(TAG, "FCM push notification received"); + FetchForegroundService.start(this); + } + + @Override + public void onDeletedMessages() { + Log.i(TAG, "FCM push notifications dropped"); + FetchForegroundService.start(this); + } + + @Override + public void onNewToken(@NonNull String rawToken) { + prefixedToken = addPrefix(rawToken); + Log.i(TAG, "new FCM token: " + prefixedToken); + ApplicationContext.dcAccounts.setPushDeviceToken(prefixedToken); + } +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..cc1d5c328cf489ea3725d5675fe4f3be2954b41c --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,500 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/assets/calls/index.html b/src/main/assets/calls/index.html new file mode 100644 index 0000000000000000000000000000000000000000..96fbfcbd8c24f35867a54cbc64d50c732721c687 --- /dev/null +++ b/src/main/assets/calls/index.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + +
+ + diff --git a/src/main/assets/fonts/Roboto-Light.ttf b/src/main/assets/fonts/Roboto-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2769c8426b9e6e1482c0c1f741c7b652abbf9fbc --- /dev/null +++ b/src/main/assets/fonts/Roboto-Light.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15ee2c8b2f3bc2d207fabd69bc31c485ed21a60da809f7bcc08af4e6b179d9cd +size 115200 diff --git a/src/main/assets/help/cs/help.html b/src/main/assets/help/cs/help.html new file mode 100644 index 0000000000000000000000000000000000000000..8db06e9100b42472f8953cd75ba3b23d4a72fb1d --- /dev/null +++ b/src/main/assets/help/cs/help.html @@ -0,0 +1,1666 @@ + + + + + +

+ + + Co je Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    You need to accept the request before you can reply.

    +
  • +
  • +

    You can also delete it if you don’t want to chat with them for now.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Podporuje Delta Chat obrázky, videa a jiné přílohy? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Kdo uvidí můj profilový obrázek? + + +

+ +
    +
  • +

    Profilový obrázek lze zvolit v nastavení. Když napíšeš svému kontaktu, +nebo přidáš nový vyfocením QR kódu, tyto kontakty automaticky uvidí tvůj profilový obrázek.

    +
  • +
  • +

    Z důvodu soukromí nikdo nevidí tvůj profilový obrázek dokud jim nenapíšeš.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + What do Pinning, Muting and Archiving mean? + + +

+ +

Use these tools to organize your chats and keep everything in its place:

+ +
    +
  • +

    Pinned chats always stay atop of the chat list. You can use them to access your most loved chats quickly or temporarily to not forget about things.

    +
  • +
  • +

    Mute chats if you do not want to get notifications for them. Muted chats stay in place and you can also pin a muted chat.

    +
  • +
  • +

    Archive chats if you do not want to see them in your chat list any longer. +Archived chats remain accessible above the chat list or via search.

    +
  • +
  • +

    When an archived chat gets a new message, unless muted, it will pop out of the archive and back into your chat list. +Muted chats stay archived until you unarchive them manually.

    +
  • +
+ +

To use the functions, long tap or right click a chat in the chat list.

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + What does the green dot mean? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + What do the ticks shown beside outgoing messages mean? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + What happens if I turn on “Delete old messages from device”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Vytvoření skupiny + + +

+ +
    +
  • Z menu v pravém horním rohu, nebo stiskem příslušného tlačítka na Androidu / iOS vyber Nový hovor a pak Nová skupina.
  • +
  • Na další obrazovce, vyber členy skupiny a zadej Název skupiny. Také můžeš vybrat obrázek skupiny.
  • +
  • Jakmile do skupiny pošleš první zprávu, všichni členové budou vyrozuměni o nové skupině a mohou do ní také psát (dokud nepošleš první zprávu členové skupiny o ní nebudou vědět).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Kdyź se nedopatřením odstraníš. + + +

+ +
    +
  • Když nejsi členem skupiny nelze se znovu připojit. Nicméně, není to velká potíž - +požádej běžnou zprávou jiného člena skupiny o znovupřipojení.
  • +
+ +

+ + + Nechci již přijímat zprávy ze skupiny. + + +

+ +
    +
  • +

    Buď se odeber ze seznamu členů a nebo vymaž celý skupinový hovor. +K opětovnému připojení v budoucnu požádej nějakého člena skupiny o znovupřidání.

    +
  • +
  • +

    Jiná možnost je “Umlčení” skupiny, což znamená nadále přijímat a také posílat zprávy, +ale nebudeš dostávat upozrnění na nově příchozí zprávy.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Více klientů najednou + + +

+ +

+ + + Lze Delta Chat používat souběžně na více zařízeních? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    On the second device, install Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    Transfer should start after a few seconds and during transfer both devices will show the progress. +Wait until it is finished on both devices.

    +
  • +
+ +

In contrast to many other messengers, after successful transfer, +both devices are completely independent. +One device is not needed for the other to work.

+ +

+ + + Troubleshooting + + +

+ +
    +
  • +

    Double-check both devices are in the same Wi-Fi or network

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Ensure there is enough storage on the destination device

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Je v plánu vytvořit webovou verzi Delta Chatu? + + +

+ +
    +
  • Zatím ne, pouze nějaké úvodní myšlenky ohledně takového vývoje.
  • +
  • Jsou 2-3 cesty jak uvést Delta Chatu na Web, ale všechny představují významné úsilí. +V tuto chvíli jsme zaměřeni na poskytování stabilní verze jako nativní aplikace ve všech +významných obchodech (Google Play / iOS / Windows / macOS / Linux repositories) .
  • +
  • Pokud nemáš dostatečná práva instalovat programy na svůj pracovní počítač, můžeš použít +přenosnou verzi pro Windows nebo AppImage pro Linux. +Všechny softwarové balíčky jsou na get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Mám zájem o technické podrobnosti. Kde najdu víc? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Yes.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Lze znovu použít můj stávající soukromý klíč? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Was Delta Chat independently audited for security vulnerabilities? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Různé + + +

+ +

+ + + Jaká oprávnění Delta Chat potřebuje? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Jak je financován vývoj Delta Chatu? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Sometimes we receive one-time donations from private individuals. +For example, in 2021 a generous individual bank-wired us 4K EUR +with the subject “keep up the good developments!”. 💜 +We use such money to fund development gatherings or to care for ad-hoc expenses +that can not easily be predicted for, or reimbursed from, public funding grants. +Receiving more donations also helps us to become more independent and long-term viable +as a contributor community.

    + + +
  • +
  • +

    Velice významnou pomocí je práce expertů a nadšenců prováděná bez nároku +na honorář či za minimální odměnu ve prospěch veřejného dobra. Je třeba +zdůraznit, že bez nich by se Delta Chat nepřibližoval ani zdaleka současnému +stavu.

    +
  • +
+ +

The monetary funding mentioned above is mostly organized by merlinux GmbH in +Freiburg (Germany), and is distributed to more than a dozen contributors world-wide.

+ +

Please see Delta Chat Contribution channels +for both monetary and other contribution possibilities.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/de/help.html b/src/main/assets/help/de/help.html new file mode 100644 index 0000000000000000000000000000000000000000..aa018f512c980156eb66b64e0d117f60f8aed135 --- /dev/null +++ b/src/main/assets/help/de/help.html @@ -0,0 +1,1532 @@ + + + + + +

+ + + Was ist Delta Chat? + + +

+ +

Delta Chat ist eine zuverlässige, dezentralisierte und sichere Instant-Messaging-App, verfügbar für Mobile- und Desktop-Plattformen.

+ + + +

+ + + Wie finde ich Leute, mit denen ich chatten kann? + + +

+ +

Beachte zunächst, dass Delta Chat ein privater Messenger ist. +Es gibt keine öffentliches Verzeichnis, du entscheiden selbst über deine Kontakte.

+ +
    +
  • Wenn du persönlich mit deinen Freunden oder Familie zusammen bist, +tippe auf das QR-Code-Symbol +auf dem Hauptbildschirm.
    +Bitte deinen Chatpartner den QR-Code mit Delta Chat zu scannen.
  • +
+ +

Für eine Kontaktaufnahme aus der Ferne, klicke im selben Bildschirm auf “Kopieren” oder “Teilen” und sende den Einladungslink über einen anderen privaten Chat.

+ +

Wartet nun, bis die Verbindung hergestellt ist.

+ +
    +
  • +

    Wenn beide Seiten online sind, wird ein Chat angezeigt und ihr könnt sicher miteinander chatten.

    +
  • +
  • +

    Wenn eine Seite offline ist oder eine schlechte Netzwerkverbindung hat, +wird die Chat-Funktion verzögert, bis die Verbindung wiederhergestellt ist.

    +
  • +
+ +

Glückwunsch! +Du verwendest jetzt automatisch eine Ende-zu-Ende-Verschlüsselung +mit deinem Kontakt. +Wenn man sich gegenseitig zu Gruppen hinzufügt, +wird eine Ende-zu-Ende-Verschlüsselung zwischen allen Mitgliedern eingerichtet.

+ +

+ + + Warum ist ein Chat als “Anfrage” markiert? + + +

+ +

Da Delta Chat ein privater Messenger ist, können dir zunächst nur Freunde und Familienmitglieder, denen du deinen QR-Code oder Einladungslink schickst, schreiben.

+ +

Deine Freunde können deine Kontaktdaten dann mit anderen Freunden teilen. Dies wird als Anfrage angezeigt.

+ +
    +
  • +

    Du musst die Anfrage akzeptieren, bevor du antworten kannst.

    +
  • +
  • +

    Du kannst sie auch “löschen”, wenn du vorerst nicht mit ihm chatten möchten.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + Wie kann ich zwei meiner Freunde miteinander in Kontakt bringen? + + +

+ +

Füge den ersten Kontakt zum Chat des zweiten Kontakts hinzu, indem du auf Paperclip Anhängen → Kontakt klickst. +Du kannst auch eine kurze Nachricht hinzufügen.

+ +

Der zweite Kontakt erhält dann die Kontaktdaten und +kann darauf tippen, um mit dem ersten Kontakt zu chatten.

+ +

+ + + Unterstützt Delta Chat Bilder, Videos und Dateianhänge? + + +

+ +
    +
  • +

    Ja. Bilder, Videos, Dateien, Sprachnachrichten und mehr können über die Paperclip Anhang- +bzw. Microphone Sprachnachricht-Buttons hinzugefügt werden

    +
  • +
  • +

    Um die Leistung zu verbessern, werden die Bilder standardmäßig optimiert und in einer kleineren Größe gesendet, aber du kannst sie auch als “Datei” senden, um das Original zu erhalten.

    +
  • +
+ +

+ + + Was sind Profile? Wie kann ich zwischen ihnen wechseln? + + +

+ +

Ein Profil besteht aus einem Namen, einem Bild und einigen zusätzlichen Informationen zum Verschlüsseln von Nachrichten. +Ein Profil existiert nur auf Ihren Geräten +und verwendet den Server nur für den Transport von Nachrichten.

+ +

Bei der Installation von Delta Chat wird ein erstes Profil erstellt.

+ +

Später kannst du auf dein Profilbild in der oberen linken Ecke tippen, um Profile hinzuzufügen +oder Profile zu wechseln.

+ +

Du kannst separate Profile für politische, familiäre oder berufliche Aktivitäten verwenden.

+ +

Vielleicht möchtest due auch erfahren, wie du Profile auf mehreren Geräten verwenden kannst.

+ +

+ + + Wer sieht mein Profilbild? + + +

+ +
    +
  • +

    Du kannst ein Profilbild in den Einstellungen hinzufügen. Wenn du deinen Kontakten eine Nachricht sendest oder sie über einen QR-Code hinzufügst, sehen diese automatisch dein Profilbild.

    +
  • +
  • +

    Aus Datenschutzgründen sieht niemand dein Profilbild, dem du nicht zuvor eine Nachricht gesendet hast.

    +
  • +
+ +

+ + + Kann ich einen Status festlegen? + + +

+ +

Ja, +Du kannst dies unter “Einstellungen → Profil → Signatur” tun. +Sobald du eine Nachricht an einen Kontakt sendest, kann dieser deine Signatur in deinem Profil sehen.

+ +

+ + + Was bedeutet Anheften, Stummschalten, Archivieren? + + +

+ +

Verwende diese Tools, um deine Chats zu organisieren:

+ +
    +
  • +

    Angeheftete Chats bleiben immer ganz oben in der Chatliste. So kannst du schnell auf deine Lieblingschats zugreifen oder du verwendest vorübergehend angeheftete Chats um Dinge nicht zu vergessen.

    +
  • +
  • +

    Stummgeschaltete Chats erhalten keine Benachrichtigungen, bleiben ansonsten aber an ihrem Platz. Du kannst auch stummgeschaltete Chats anheften.

    +
  • +
  • +

    Archiviere Chats, wenn du diese nicht mehr in deiner Chatliste sehen möchtest. Archivierte Chats bleiben oberhalb der Chatliste oder über die Suche zugänglich.

    +
  • +
  • +

    Wenn ein archivierter Chat eine neue Nachricht erhält, wird er, sofern er nicht stummgeschaltet ist, wieder in die normale Chatliste verschoben. Stummgeschaltete Chats bleiben archiviert, bis du sie manuell aus dem Archiv entfernst.

    +
  • +
+ +

Um die Funktionen zu nutzen, lang auf einen Chat in der Chatliste tippen oder den Chat mit der rechten Maustaste anklicken.

+ +

+ + + Wie funktionieren “Gespeicherte Nachrichten”? + + +

+ +

Gespeicherte Nachrichten ist ein Chat, den du verwenden kannst, um dir Nachrichten zu merken und wiederzufinden.

+ +
    +
  • +

    Tippen in einem beliebigen Chat lange auf eine Nachricht oder klicken mit der rechten Maustaste darauf und wähle Speichern.

    +
  • +
  • +

    Gespeicherte Nachrichten werden mit dem Symbol +Saved icon +neben dem Datum markiert

    +
  • +
  • +

    Öffnen später den Chat „Gespeicherte Nachrichten“; dort siehst du die gespeicherten Nachrichten. +Durch Tippen auf Arrow-right icon, +kannst du zu der ursprünglichen Nachricht im ursprünglichen Chat zurückkehren

    +
  • +
  • +

    Schließlich kannst du auch „Gespeicherte Nachrichten“ verwenden, um persönliche Notizen zu machen - öffnen den Chat, gib etwas ein, fügen ein Foto oder eine Sprachnachricht hinzu usw.

    +
  • +
  • +

    Da „Gespeicherte Nachrichten“ synchronisiert werden, können sie sehr praktisch für die Übertragung von Daten zwischen Geräten sein

    +
  • +
+ +

Nachrichten bleiben gespeichert, auch wenn sie bearbeitet oder gelöscht werden - +sei es durch den Absender, durch Automatisches Löschen oder durch verschwindende Nachrichten anderer Chats.

+ +

+ + + Was bedeutet der grüne Punkt? + + +

+ +

Manchmal ist ein “grüner Punkt” neben dem Avatar eines Kontakts. Er bedeutet, dass der Kontakt kürzlich von dir gesehen wurde, in den letzten 10 Minuten, z.B. da du eine Nachricht oder eine Lesebestätigung empfangen hast.

+ +

Dies ist also kein Echtzeit-Online-Status - und auch andere werden nicht immer sehen, wenn du online bist.

+ +

+ + + Was bedeuten die Häkchen neben den ausgehenden Nachrichten? + + +

+ +
    +
  • +

    Ein Häkchen bedeutet, dass die Nachricht erfolgreich versandt wurde.

    +
  • +
  • +

    Zwei Häkchen bedeuten, dass mindestens ein Gerät des Empfängers zurückgemeldet hat, die Nachricht empfangen zu haben.

    +
  • +
  • +

    Lesebestätigungen können deaktiviert werden. D.h. auch wenn Sie nur ein Häkchen sehen, kann die Nachricht gelesen worden sein.

    +
  • +
  • +

    Umgekehrt bedeuten zwei Häkchen nicht automatisch, dass ein Mensch die Nachricht gelesen oder verstanden hat ;)

    +
  • +
+ +

+ + + Schreibfehler korrigieren und Nachrichten nach dem Senden löschen + + +

+ +
    +
  • +

    Du kannst den Text deiner Nachrichten nach dem Senden bearbeiten. +Tippen dazu lange auf die Nachricht oder klicke mit der rechten Maustaste auf die Nachricht und wähle Bearbeiten oder Edit icon

    +
  • +
  • +

    Wenn du versehentlich eine Nachricht gesendet hast, +wähle im selben Menü Löschen und dann Für alle löschen.

    +
  • +
+ +

Während bei bearbeiteten Nachrichten das „Bearbeitet“ neben dem Datum erscheint, +werden gelöschte Nachrichten ohne Markierung im Chat entfernt. +Es werden keine Benachrichtigungen verschickt und es gibt kein Zeitlimit.

+ +

Beachten, dass die ursprüngliche Nachricht dennoch von Chatteilnehmern empfangen worden sein könnte, +die die Nachricht bereits beantwortet, weitergeleitet, gespeichert, mit einem Screenshot versehen oder anderweitig kopiert haben könnten.

+ +

+ + + Wie funktionieren “Verschwindende Nachrichten”? + + +

+ +

Schalte “Verschwindende Nachrichten” +oben rechts im Chatfenster, +durch Auswahl einer Zeitspanne +zwischen 5 Minuten und 1 Jahr ein.

+ +

Bis die Einstellung wieder ausgeschaltet wird, +kümmern sich die Delta-Chat-Apps der Chat-Teilnehmer +um das Löschen der Nachrichten +nach der gewählten Zeitspanne. +Die Zeitspanne beginnt, +wenn der Empfänger die Nachricht zum ersten Mal in Delta Chat ansieht. +Die Nachrichten werden dann +sowohl auf den Servers, +als auch in den Apps selbst gelöscht.

+ +

Beachte, dass du dich auf verschwindende Nachrichten nur so lange verlassen kannst, wie du deinen Chat-Partnern vertraust; +böswillige Chatpartner können Fotos machen, +oder auf andere Weise Nachrichten vor dem Löschen speichern, kopieren oder weiterleiten.

+ +

Abgesehen davon, wenn ein Chat-Partner Delta Chat deinstalliert, kann es länger dauern, bis die (ohnehin verschlüsselten) Nachrichten vom Server gelöscht werden.

+ +

+ + + Was passiert, wenn ich “Alte Nachrichten vom Gerät löschen” aktiviere? + + +

+ +
    +
  • Wenn du Speicherplatz auf deinem Gerät sparen möchtest, kannst du alte Nachrichten automatisch löschen lassen.
  • +
  • Hierzu, öffne die “Chats und Medien”-Einstellungen und dort “Alte Nachrichten vom Gerät löschen”. Du kannst einen Zeitraum zwischen “1 Stunde” und “1 Jahr” festlegen; auf diese Weise werden alle Nachrichten von Ihrem Gerät gelöscht, sobald sie älter als angegeben sind.
  • +
+ +

+ + + Wie kann ich mein Chat-Profil löschen? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Gruppen + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Eine Gruppe anlegen + + +

+ +
    +
  • Wähle Neuer Chat und dann Neue Gruppe aus dem Menü oben rechts oder über das entsprechende Symbol unter Android/iOS.
  • +
  • Wähle auf dem folgenden Bildschirm die Gruppenmitglieder aus und klicke auf das Häkchen in der oberen rechten Ecke. Danach kannst du einen Gruppennamen und auch einen Gruppenbild festlegen.
  • +
  • Sobald du die erste Nachricht in die Gruppe schreibst, werden alle Mitglieder über die neue Gruppe informiert und können in der Gruppe antworten (solange du keine Nachricht in die Gruppe schreibst, ist die Gruppe für die Gruppenmitglieder nicht sichtbar).
  • +
+ +

+ + + Mitglieder hinzufügen und entfernen + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Ich habe mich selbst versehentlich gelöscht. + + +

+ +
    +
  • Da du kein Gruppenmitglied mehr bist, kannst du sich selbst nicht mehr hinzufügen. +Kein Problem, bitte einfach ein anderes Gruppenmitglied in einem normalen Chat, dich hinzuzufügen.
  • +
+ +

+ + + Ich möchte keine Nachrichten einer Gruppe mehr empfangen. + + +

+ +
    +
  • +

    Lösche dich entweder aus der Mitgliederliste oder lösche den gesamten Chat. +Wenn du der Gruppe später erneut beitreten möchtest, bitten ein anderes Gruppenmitglied, dich hinzuzufügen.

    +
  • +
  • +

    Alternativ kannst du eine Gruppe auch “stummschalten” - dies bedeutet, dass du weiterhin alle Nachrichten erhälst und neue schreiben kannst, aber nicht mehr über neue Nachrichten informiert wirst.

    +
  • +
+ +

+ + + Eine Gruppe klonen + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-Chat-Apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Wo bekomme ich In-Chat-Apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + Wie privat sind In-Chat-Apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + Wie kann mich meine eigene In-Chat-Apps erstellen? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Sofortige Nachrichtenzustellung und Push-Benachrichtigungen + + +

+ +

+ + + Was sind Push-Benachrichtigungen? Wie kann ich Nachrichten sofort erhalten? + + +

+ +

Push-Benachrichtigungen werden von Apples und Googles „Push-Diensten“ an das Gerät des Benutzers gesendet, +so dass eine inaktive Delta-Chat-App im Hintergrund Nachrichten erhalten +und Benachrichtigungen auf dem Telefon des Nutzers anzeigen kann.

+ +

Push-Benachrichtigungen funktionieren mit allen Chatmail-Servern auf

+ +
    +
  • +

    iOS-Geräten, durch die Integration mit den Apple-Push-Diensten.

    +
  • +
  • +

    Android-Geräten, durch die Integration des Google-FCM-Push-Dienstes, +auch auf Geräten, die microG +anstelle von proprietärem Google-Code auf dem Telefon.

    +
  • +
+ +

+ + + Sind Push-Benachrichtigungen auf iOS-Geräten aktiviert? Gibt es Alternativen? + + +

+ +

Ja, Delta Chat verwendet automatisch Push-Benachrichtigungen für Chatmail-Profile. +Und nein, es gibt für Apple-Telefonen keine Alternative, Push-Benachrichten zuzustellen; +Apple-Geräte erlauben es Delta Chat nicht, Daten im Hintergrund abzurufen. +Push-Benachrichtigungen werden für iOS-Nutzer automatisch aktiviert, da +Delta Chats datenschutzwahrendes Push-Benachrichtigungssystem +keine Daten an Apple weitergibt, die Apple nicht bereits hat.

+ +

+ + + Sind Push-Benachrichtigungen auf Android-Geräten aktiviert/erforderlich? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In den Delta-Chat-Einstellungen „Benachrichtigungen“ für „Sofortige Benachrichtigungen“ +kannst du die folgenden Einstellungen ändern, die alle Chat-Profile betreffen:

+ +
    +
  • +

    Hintergrundverbindung verwenden: Wenn du keinen Push-Dienst verwendest, kannst du die „Batterie-Optimierung“ für Delta Chat deaktivieren, damit Nachrichten im Hintergrund abgerufen werden können. Dabei kann es jedoch zu Verzögerungen von Minuten bis Stunden kommen. +Einige Android-Hersteller schränken Apps sogar vollständig ein +(siehe dontkillmyapp.com) +und Delta Chat zeigt möglicherweise keine eingehenden Nachrichten an, bis du die App erneut manuell öffnest.

    +
  • +
  • +

    Hintergrundverbindung erzwingen: Dies ist die Ausweichoption wenn die vorherigen Optionen nicht verfügbar sind oder keine „sofortige Zustellung“ erreichen. Die Aktivierung dieser Option führt zu einer permanenten Benachrichtigung auf Ihrem Telefon, die bei neueren Android-Telefonen manchmal „verkleinert“ werden kann.

    +
  • +
+ +

Beide „Hintergrundverbindung“-Optionen sind energiesparend und +können sicher ausprobiert werden, wenn du feststellst, dass Nachrichten nur mit großer Verzögerung ankommen.

+ +

+ + + Wie privat sind Delta-Chat-Push-Benachrichtigungen? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

So verwendet Delta Chat Push-Benachrichtigungen:

+ +
    +
  • +

    Eine Delta-Chat-Anwendung erhält lokal ein „Geräte-Token“, verschlüsselt und speichert es +auf dem Chatmail-Server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

Der zentrale Delta-Chat-Benachrichtigungs-Proxy ist klein und vollständig in Rust implementiert +und vergisst die Geräte-Token, sobald Apple/Google/etc. sie verarbeitet hat, +normalerweise innerhalb weniger Millisekunden.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Aufgrund dieses umfassenden Datenschutzkonzepts würde sogar die Beschlagnahmung eines Chatmail-Servers, +oder die vollständige Beschlagnahmung des zentralen Delta-Chat-Benachrichtigungsproxys +keine privaten Informationen preisgeben, die den zentralen Push-Diensten nicht bereits vorliegen.

+ +

+ + + Warum integriert sich Delta Chat in zentralisierte, proprietäre Apple/Google-Push-Dienste? + + +

+ +

Delta Chat ist ein freier, quelloffener, dezentraler Messenger mit freier Serverwahl, +aber wir wollen, dass die Nutzer eine zuverlässige „Sofortzustellung“ von Nachrichten haben, +wie sie es von WhatsApp, Signal oder Telegram kennen, +ohne im Vorfeld Fragen zu stellen, die eher für erfahrene Nutzer oder Entwickler geeignet sind.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Mehrere Geräte verwenden + + +

+ +

+ + + Kann ich Delta Chat auf mehreren Geräten zur selben Zeit verwenden? + + +

+ +

Ja. Du kannst dasselbe Profil auf mehreren Geräten verwenden:

+ +
    +
  • +

    Stelle sicher, dass sich beide Geräte im selben Wi-Fi oder Netzwerk befinden

    +
  • +
  • +

    Gehen auf dem ersten Gerät zu Einstellungen → Zweites Gerät hinzufügen, entsperre den Bildschirm, falls erforderlich, und warte einen Moment, bis ein QR-Code angezeigt wird

    +
  • +
  • +

    Auf dem zweiten Gerät Delta Chat installieren

    +
  • +
  • +

    Auf dem zweiten Gerät Delta Chat starten, “Als Zweitgerät hinzufügen” wählen und den QR-Code vom ersten Gerät scannen

    +
  • +
  • +

    Die Übertragung sollte nach ein paar Sekunden beginnen und während der Übertragung zeigen beide Geräte den Fortschritt an. Warte, bis der Vorgang auf beiden Geräten abgeschlossen ist.

    +
  • +
+ +

Im Gegensatz zu vielen anderen Messengern, sind nach erfolgreicher Übertragung beide **Geräte völlig unabhängig voneinander. Das eine Gerät wird nicht benötigt, damit das Andere funktioniert.

+ +

+ + + Fehlersuche + + +

+ +
    +
  • +

    Vergewissere dich, dass beide Geräte mit dem gleichen Wi-Fi, WLAN oder Netzwerk verbunden sind.

    +
  • +
  • +

    Unter Windows, Systemsteuerung / Netzwerk und Internet öffnen +und sicherstellen, dass Privates Netzwerk als “Netzwerkprofiltyp” ausgewählt ist. +(nach der Übertragung kann wieder der ursprüngliche Wert verwendet werden)

    +
  • +
  • +

    Auf iOS, sicherstellen, dass „Systemeinstellungen / Apps / Delta Chat / Lokales Netzwerk“ eingeschaltet ist

    +
  • +
  • +

    Auf macOS, „Systemeinstellungen / Datenschutz & Sicherheit / Lokales Netzwerk / Delta Chat“ aktivieren

    +
  • +
  • +

    Dein System verfügt möglicherweise über eine “Personal Firewall”; diese sind dafür bekannt, Probleme zu verursachen (insbesondere bei Windows). Deaktiviere die Personal Firewall für Delta Chat auf beiden Seiten und versuch es erneut

    +
  • +
  • +

    In Gastnetzwerken z.B. der Fritz!Box, können Geräte möglicherweise nicht miteinander kommunizieren. +Verwende nach Möglichkeit ein Nicht-Gast-Netzwerk. Wenn du Zugriff auf den Router hast, kannst du auch die Kommunikation der Geräte untereinander für die Dauer der Übertragung erlauben.

    +
  • +
  • +

    Wenn du immer noch Probleme bei der Verwendung desselben Netzwerks hast, +versuche, einen Mobilen Hotspot auf einem Gerät zu öffnen und dich mit dem anderen Gerät in dieses WLAN einzuwählen.

    +
  • +
  • +

    Vergewissere dich, dass das Zielgerät über genügend Speicher verfügt

    +
  • +
  • +

    Wenn die Übertragung begonnen hat, stelle sicher, dass die Geräte aktiv bleiben und nicht ausgehen. Beende Delta Chat nicht. (wir bemühen uns, die App im Hintergrund laufen zu lassen, aber Systeme neigen dazu, Apps zu beenden, leider)

    +
  • +
  • +

    Du bist auf dem Zielgerät bereits eingeloggt? Du kannst mehrere Profile pro Gerät verwenden, füge einfach ein weiteres Konto hinzu

    +
  • +
  • +

    Wenn du immer noch Probleme hast oder wenn du keinen QR-Code scannen kannst, versuche die manuelle Übertragung wie unten beschrieben

    +
  • +
+ +

+ + + Manueller Transfer + + +

+ +

Diese Methode wird nur empfohlen, wenn “Zweites Gerät hinzufügen”, wie oben beschrieben, nicht funktioniert.

+ +
    +
  • Auf dem alten Gerät, gehe zu “Einstellungen → Chats und Medien → Chats auf externem Speicher speichern”. Gib deine PIN, dein Muster oder dein Passwort zum Entsperren des Bildschirms ein. Anschließend kannst du auf “Backup starten” klicken. Dadurch wird die Backup-Datei auf deinem Gerät gespeichert. Jetzt musst du sie auf das andere Gerät übertragen.
  • +
  • Auf dem neuen Gerät, auf dem Anmeldebildschirm, wähle “Ich habe bereits ein Profil” und dann “Wiederherstellen aus Backup”. Nach dem Import sind deine Chats, Medien und Einstellungen auf das neue Gerät kopiert. +
      +
    • Wenn du iOS verwendest und auf Schwierigkeiten stößt, hilft dir vielleicht diese Anleitung.
    • +
    +
  • +
  • Du bist nun synchronisiert und kannst beide Geräte zum Senden und Empfangen von Ende-zu-Ende-verschlüsselten-Nachrichten mit deinen Kommunikationspartnern verwenden.
  • +
+ +

+ + + Gibt es Pläne für eine Delta-Chat-Web-Anwendung? + + +

+ +
    +
  • Es gibt keine direkten Pläne, aber einige vorläufige Gedanken.
  • +
  • Es gibt 2-3 Möglichkeiten, einen Delta-Chat-Web-Client einzuführen, aber sie bedeuten alle immense Arbeit. Im Moment fokussieren wir uns darauf, stabile native Apps in den Appstores (Google Play/iOS/Windows/macOS/Linux repositories) anzubieten.
  • +
  • Solltest du einen Web-Client benötigen, weil du auf deinem Arbeitsrechner keine Software installieren darfst, kannst du den portablen Windows-Desktop-Client bzw. Applmage für Linux nutzen. Du findest diese unter get.delta.chat.
  • +
+ +

+ + + Erweitert + + +

+ +

+ + + Experimentelle Features + + +

+ +

Unter Einstellungen → Erweitert → Experimentelle Features +kannst du unfertige Features ausprobieren, an denen gearbeitet wird.

+ +

Die Features können instabil sein und geändert oder entfernt werden.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + Was ist “Statistik an Delta Chat Entwickler senden”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Kann ich eine klassische E-Mail-Adresse mit Delta Chat verwenden? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + Wie kann ich ein Chat-Profil mit einer klassischen E-Mail-Adresse als Relay konfigurieren? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + Ich möchte meinen eigenen Server für Delta Chat verwalten. Gibt es Empfehlungen? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Ich bin an technischen Details interessiert. Gibt es hierzu weitere Infos? + + +

+ + + +

+ + + Verschlüsselung und Sicherheit + + +

+ +

+ + + Welche Standards werden für die Ende-zu-Ende-Verschlüsselung verwendet? + + +

+ +

Delta Chat verwendet eine sichere Teilmenge des OpenPGP-Standards, um eine automatische End-to-End-Verschlüsselung mit folgenden Protokollen bereitzustellen:

+ +
    +
  • +

    Secure-Join +zum Austausch von Verschlüsselungsinformationen durch Scannen von QR-Codes oder „Einladungslinks“.

    +
  • +
  • +

    Autocrypt wird verwendet, um automatisch eine Ende-zu-Ende-Verschlüsselung zwischen Kontakten und allen Mitgliedern einer Gruppe herzustellen.

    +
  • +
  • +

    Teilen eines Kontakts im Chat +ermöglicht es den Empfängern, eine Ende-zu-Ende-Verschlüsselung mit dem Kontakt zu verwenden.

    +
  • +
+ +

Delta Chat fragt keine OpenPGP-Keyserver ab, veröffentlicht dort keine Daten und interagiert auch sonst nicht mit diesen.

+ +

+ + + Wie kann ich wissen, ob Nachrichten Ende-zu-Ende-verschlüsselt sind? + + +

+ +

Alle Nachrichten in Delta Chat sind standardmäßig Ende-zu-Ende-verschlüsselt. +Seit der Veröffentlichung von Delta Chat Version 2 (Juli 2025) gibt es keine Schlösser oder ähnliche Markierungen mehr an Ende‑zu‑Ende-verschlüsselten Nachrichten.

+ +

+ + + Kann ich Nachrichten ohne Ende-zu-Ende-Verschlüsselung empfangen oder senden? + + +

+ +

Wenn du die Standard-Chatmail-Relays verwendest, ist es unmöglich, Nachrichten ohne End-to-End-Verschlüsselung zu empfangen oder zu senden.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + Was bedeutet das grüne Häkchen in einem Kontaktprofil? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

Für eine ausführlichere Diskussion der “Garantierten Ende-zu-Ende-Verschlüsselung”, +siehe Secure-Join-Protokolle +und dort speziell den Abschnitt zu “Verified Groups”, dem technischen Begriff +für “Chats mit grünem Häkchen” oder “Garantierter Ende-zu-Ende-Verschlüsselung”.

+ +

+ + + Sind Anhänge (Bilder, Dateien, Audio usw.) Ende-zu-Ende-verschlüsselt? + + +

+ +

Ja.

+ +

Wenn wir von einer “Ende-zu-Ende-verschlüsselten Nachricht” sprechen +meinen wir immer, dass eine ganze Nachricht verschlüsselt ist, +einschließlich aller Anhänge +und Anhang-Metadaten wie Dateinamen.

+ +

+ + + Ist OpenPGP sicher? + + +

+ +

Ja, Delta Chat verwendet ein sicheres subset von OpenPGP +das verlangt, dass die gesamte Nachricht ordnungsgemäß verschlüsselt und signiert wurde. +Als Beispiel, werden “angehängte Signaturen” nicht als sicher behandelt.

+ +

Die meisten öffentlich diskutierten OpenPGP-Probleme +resultieren in Wirklichkeit aus schlechter Usability oder schlechter Implementierung von Tools oder Anwendungen - oder beidem. +Es ist besonders wichtig, zwischen OpenPGP, dem IETF-Verschlüsselungsstandard +und GnuPG (GPG), einem Kommandozeilenprogramm, das OpenPGP implementiert, zu unterscheiden. +In vielen öffentlichen Kritiken zu OpenPGP wird GnuPG diskutiert, das Delta Chat nie verwendet hat. +Delta Chat verwendet stattdessen die OpenPGP-Rust-Implementierung rPGP, +die als ein unabhängiges “pgp”-Paket, +verfügbar ist und 2019 und 2024 sicherheitsgeprüft wurde.

+ +

Unser Ziel ist, zusammen mit anderen OpenPGP-Implementierungen, +die Sicherheitseigenschaften durch das im Sommer 2023 angenommene +IETF OpenPGP Crypto-Refresh weiter zu verbessern.

+ +

+ + + Wurden Alternativen zu OpenPGP für die Ende-zu-Ende-Verschlüsselung in Betracht gezogen? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat verfolgt einen ganzheitlichen Ansatz bei der “nutzbaren Sicherheit”: +Wir arbeiteten mit vielen Aktivistengruppen sowie mit +renommierten Forschern wie TeamUSEC zusammen, +um die tatsächlichen Ergebnisse der Benutzer gegen Sicherheitsbedrohungen zu verbessern. +Das Wire-Protokoll und der Standard für die Einrichtung der Ende-zu-Ende-Verschlüsselung ist +nur ein Teil der Ergebnisse, +siehe auch unsere Antworten auf Gerätebeschlagnahmung +und Metadaten Fragen.

+ +

+ + + Ist Delta Chat anfällig für EFAIL? + + +

+ +

Nein, Delta Chat war nie anfällig für EFAIL. +Delta Chats OpenPGP-Implementierung rPGP +verwendet beim Verschlüsseln von Nachrichten “Modification Detection Codes” +und gibt Fehler zurück, +wenn dieser falsch ist.

+ +

Delta Chat war auch nie anfällig für den EFAIL-Angriff “Direct Exfiltration”, +da nur multipart/encrypted Nachrichten entschlüsselt werden, +die genau einen verschlüsselten und signierten Teil enthalten; +so wie in der Autocrypt-Level-1-Spezifikation definiert.

+ +

+ + + Sind mit dem Mail-Symbol markierte Nachrichten im Internet sichtbar? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + Wie schützt Delta Chat Metadaten in Nachrichten? + + +

+ +

Anders als die meisten anderen Messenger +speichern Delta-Chat-Apps keine Metadaten über Kontakte oder Gruppen auf Servern. Auch nicht in verschlüsselter Form. +Stattdessen werden alle Gruppen-Metadaten durchgängig verschlüsselt und ausschließlich auf den Endgeräten der Nutzer gespeichert.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

Alle anderen Metadaten zu Nachrichten, Kontakten und Gruppen befinden sich im Ende-zu-Ende-verschlüsselten Teil der Nachrichten.

+ +

+ + + Wie schützt man Metadaten und Kontakte, wenn ein Gerät beschlagnahmt wird? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Unterstützt Delta Chat „Sealed Sender“? + + +

+ +

Nein, noch nichts.

+ +

Der Signal-Messenger führte 2018 “Sealed Sender” ein +um seine Serverinfrastruktur darüber im Unklaren zu lassen, wer eine Nachricht an eine Gruppe von Empfängern sendet. +Dies ist besonders wichtig, weil der Signal-Server die Handynummer jedes Kontos kennt, +die in der Regel mit einer Passidentität verbunden ist.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Unterstützt Delta Chat “Perfect Forward Secrecy”? + + +

+ +

Nein, noch nichts.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Unterstützt Delta Chat Post-Quantum-Verschlüsselung? + + +

+ +

Nein, noch nichts.

+ +

Delta Chat verwendet die Rust OpenPGP-Bibliothek rPGP +die den neuesten IETF Post-Quantum-Cryptography OpenPGP Entwurf unterstützt. +Wir beabsichtigen, PQC-Unterstützung zum chatmail core hinzuzufügen, sobald der Entwurf bei der IETF in Zusammenarbeit mit anderen OpenPGP-Implementierern fertiggestellt ist.

+ +

+ + + Wie kann ich die Verschlüsselung manuell überprüfen? + + +

+ +

Du kannst den Status der Ende-zu-Ende-Verschlüsselung manuell im Dialog “Verschlüsselung” +(Android/iOS: Benutzerprofil, Desktop: Rechtsklick auf den Chat eines Benutzers) überprüfen. +Delta Chat zeigt dort zwei Fingerabdrücke an. +Wenn die gleichen Fingerabdrücke auf Ihrem eigenen Gerät und auf dem Gerät Ihres Kontakts erscheinen, +ist die Verbindung sicher.

+ +

+ + + Kann ich meinen existierenden privaten Schlüssel weiter verwenden? + + +

+ +

Nein.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Wurde Delta Chat unabhängig auf Sicherheitslücken geprüft? + + +

+ +

Ja, mehrfach. +Das Delta-Chat-Projekt wird kontinuierlich unabhängigen Sicherheitsaudits und -analysen unterzogen:

+ +
    +
  • +

    Im Dezember 2024 fand eine von NLNET in Auftrag gegebene Bewertung von rPGP durch Radically Open Security statt. +rPGP wird für die OpenPGP-Ende-zu-Ende-Verschlüsselung verwendet. +Im Zusammenhang mit den Ergebnissen dieser Prüfung wurden zwei Hinweise veröffentlicht:

    + + + +

    Die in diesen Hinweisen beschriebenen Probleme wurden behoben und sind Bestandteil der Delta Chat Veröffentlichungen in allen Appstores seit Dezember 2024.

    +
  • +
  • +

    Im März 2024 erhielten wir von der Forschungsgruppe “Applied Cryptography” der ETH Zürich eine umfassende Sicherheitsanalyse und haben alle aufgeworfenen Fragen adressiert. +Weitere Informationen findest du in unserem Blogbeitrag über Hardening Guaranteed End-to-End encryption und in der hinterher publizierten Kryptografischen Analyse von Delta Chat

    +
  • +
  • +

    Im April 2023 haben wir Sicherheits- und Datenschutzprobleme mit dem “In Chats geteilten Apps”-Feature behoben, die mit Fehlern beim Sandboxing, insbesondere mit Chromium zusammenhängen. Wir haben daraufhin eine unabhängige Sicherheitsprüfung von Cure53 durchführen lassen, und alle gefundenen Probleme wurden mit den im April 2023 veröffentlichten 1.36 Releases behoben. Siehe hier für die vollständige Hintergrundgeschichte.

    +
  • +
  • +

    Im März 2023 analysierte Cure53 sowohl die Transportverschlüsselung von Delta Chats Netzwerkverbindungen als auch das reproduzierbare Mailserver-Setup wie auf dieser Seite empfohlen. Du kannst mehr über das Audit in unserem Blog lesen oder du liest den vollständigen Bericht hier.

    +
  • +
  • +

    Im Jahr 2020 analysierte Include Security Delta Chats Rust core, IMAP,SMTP, und TLS Bibliotheken. +Es wurden keine kritischen oder hochgradig gefährlichen Probleme gefunden. Der Bericht wies auf einige Schwachstellen mittlerer Schwere hin - sie stellen für sich genommen keine Bedrohung für Delta-Chat-Benutzer dar, da sie von der Umgebung abhängen, in der Delta Chat verwendet wird. Aus Gründen der Benutzerfreundlichkeit und der Kompatibilität können wir nicht alle Schwachstellen beseitigen und haben beschlossen, Sicherheitsempfehlungen für bedrohte Benutzer zu geben. Du kannst den vollständigen Bericht hier lesen.

    +
  • +
  • +

    Im Jahr 2019 analysierte Include Security die von Delta Chat verwendeten PGP- und RSA- Bibliotheken. +Es wurden keine kritischen Probleme gefunden, aber zwei Probleme mit hohem Schweregrad, die wir anschließend behoben haben. Außerdem wurden ein mittelschweres und einige weniger schwerwiegende Probleme gefunden, aber es gab keine Möglichkeit, diese Schwachstellen in der Delta-Chat-Implementierung auszunutzen. Einige dieser Schwachstellen haben wir dennoch nach Abschluss des Audits behoben. Du kannst den vollständigen Bericht hier lesen.

    +
  • +
+ +

+ + + Verschiedenes + + +

+ +

+ + + Welche App-Berechtigungen benötigt Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Wo können meine Freunde Delta Chat finden? + + +

+ +

Delta Chat ist für alle großen und einige kleinere Plattformen verfügbar:

+ + + +

+ + + Wie wird Delta Chat finanziert? + + +

+ +

Delta Chat erhält kein Risikokapital, ist nicht verschuldet und steht unter keinem Druck, große Gewinne zu erzielen oder Nutzer, deren Freunde und Familie an Werbekunden zu verkaufen (oder Schlimmeres). +Wir nutzen vielmehr öffentliche Finanzierungsquellen, die bisher aus der EU und den USA stammen, um ein dezentrales und diverses Chat-Messaging-Ökosystem zu schaffen, basierend auf freien und quelloffenen Entwicklungen der Gemeinschaft.

+ +

Konkret wurden die Delta-Chat-Entwicklungen bisher aus diesen Quellen finanziert:

+ +
    +
  • +

    Das EU-Projekt NEXTLEAP finanzierte 2017 und 2018 die Entwicklung und Implementierung von “Verifizierten Gruppen” und “Setup Kontakt” und half auch bei der Integration der Ende-zu-Ende-Verschlüsselung durch Autocrypt.

    +
  • +
  • +

    Der Open Technology Fund hat Delta Chat erstmals 2018/2019 bezuschusst; mit dieser Förderung (~$200K) wurden hauptsächlich die Android-App verbessert sowie das Release der Desktop-App in einer Betaversion ermöglicht. Basierend auf Nutzererfahrungen im Menschenrechtskontext wurden zudem verschiedene Funktionen entwickelt, siehe unseren Bericht Needfinding and UX report. +Die zweite Förderung 2019/2020 (~$300K) half uns bei der Erstellung der iOS-Version, unsere Kernbibliothek in die Programmiersprache “Rust” zu konvertieren und neue Funktionen für alle Plattformen bereitzustellen.

    +
  • +
  • +

    Die NLnet-Stiftung bewilligte 2019/2020 46K EUR für die Fertigstellung von Rust-/Python-Bindungs und die Einrichtung eines Chat-Bot-Ökosystems.

    +
  • +
  • +

    Im Jahr 2021 erhielten wir weitere EU-Mittel für zwei “Next-Generation-Internet”-Anträge, nämlich für EPPD - E-Mail-Provider-Portabilitätsverzeichnis (~97K EUR) und AEAP - E-Mail-Adressportierung (~90K EUR). Ziel sind bessere Unterstützung von Mehrfachkonten, verbesserten QR-Code-Kontakt- und -Gruppen-Setups sowie Netzwerkverbesserungen auf allen Plattformen.

    +
  • +
  • +

    Von Ende 2021 bis März 2023 erhielten wir eine Internet-Freedom-Finanzierung (500K USD) vom U.S. Bureau of Democracy, Human Rights and Labor (DRL). Diese Finanzierung unterstützte unsere langjährigen Ziele, Delta Chat benutzerfreundlicher und kompatibel mit einer breiten Palette von E-Mail-Servern weltweit zu machen, sowie widerstandsfähiger und sicherer an Orten, die häufig von Internetzensur und Abschaltungen betroffen sind.

    +
  • +
  • +

    2023-2024 schlossen wir erfolgreich das vom OTF finanzierte +Secure-Chatmail-Projekt ab. +Dieses fügt “Garantierte Verschlüsselung”, +das Chatmail-Server-Netzwerk +und „Instant Onboarding“ allen ab April 2024 veröffentlichten Anwendungen hinzu.

    +
  • +
  • +

    2023 und 2024 wurden wir in das Next-Generation-Internet-Programm (NGI) +für unsere Arbeit an Webxdc-PUSH aufgenommen, +zusammen mit Kooperationspartnern, die an +Webxdc-Evolve, +Webxdc-XMPP, +DeltaTouch und +DeltaTauri. +Alle diese Projekte sind teilweise abgeschlossen oder sollen Anfang 2025 abgeschlossen werden.

    +
  • +
  • +

    Manchmal erhalten wir einmalige Spenden von Privatpersonen, wofür wir sehr dankbar sind. Im Jahr 2021 hat uns zum Beispiel eine großzügige Privatperson 4000 EUR überwiesen mit dem Betreff “Weiter so!” 💜 Wir verwenden dieses Geld zur Finanzierung von Entwicklungstreffen oder zur Deckung von Ad-hoc-Ausgaben, die nicht ohne weiteres vorhersehbar sind oder nicht aus öffentlichen Fördermitteln erstattet werden können. +Der Erhalt von Spenden hilft uns auch, unabhängiger und langfristig lebensfähig zu werden, als Gemeinschaft.

    + + +
  • +
  • +

    Last but by far not least beteiligen sich verschiedene ExpertInnen und Engagierte pro bono an Delta Chat. Sie erhalten dafür teils nur wenig, oftmals sogar überhaupt kein Geld. Ohne sie wäre Delta Chat nicht im entferntesten das, was es heute ist!

    +
  • +
+ +

Die oben aufgeführte finanzielle Förderung wird hauptsächlich von der merlinux GmbH in Freiburg (Deutschland) organisiert und an mehr als ein Dutzend Mitwirkende weltweit verteilt.

+ +

Möglichkeiten mitzuwirken findest du auf der Delta-Chat-Seite “Mitwirken”.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/edit-icon.png b/src/main/assets/help/edit-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6da58203587a4e3b6b8d5c72a311e63202f7422c Binary files /dev/null and b/src/main/assets/help/edit-icon.png differ diff --git a/src/main/assets/help/email-icon.png b/src/main/assets/help/email-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1cf1d8fe3dc0bd3829d0a418710b03b22eefeb Binary files /dev/null and b/src/main/assets/help/email-icon.png differ diff --git a/src/main/assets/help/en/help.html b/src/main/assets/help/en/help.html new file mode 100644 index 0000000000000000000000000000000000000000..fe024d151c606b6b1764cf77b14951b34ae81302 --- /dev/null +++ b/src/main/assets/help/en/help.html @@ -0,0 +1,1674 @@ + + + + + +

+ + + What is Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    You need to accept the request before you can reply.

    +
  • +
  • +

    You can also delete it if you don’t want to chat with them for now.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Does Delta Chat support images, videos and other attachments? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Who sees my profile picture? + + +

+ +
    +
  • +

    You can add a profile picture in your settings. If you write to your contacts +or add them via QR code, they automatically see it as your profile picture.

    +
  • +
  • +

    For privacy reasons, no one sees your profile picture until you write a +message to them.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + What do Pinning, Muting and Archiving mean? + + +

+ +

Use these tools to organize your chats and keep everything in its place:

+ +
    +
  • +

    Pinned chats always stay atop of the chat list. You can use them to access your most loved chats quickly or temporarily to not forget about things.

    +
  • +
  • +

    Mute chats if you do not want to get notifications for them. Muted chats stay in place and you can also pin a muted chat.

    +
  • +
  • +

    Archive chats if you do not want to see them in your chat list any longer. +Archived chats remain accessible above the chat list or via search.

    +
  • +
  • +

    When an archived chat gets a new message, unless muted, it will pop out of the archive and back into your chat list. +Muted chats stay archived until you unarchive them manually.

    +
  • +
+ +

To use the functions, long tap or right click a chat in the chat list.

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + What does the green dot mean? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + What do the ticks shown beside outgoing messages mean? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + What happens if I turn on “Delete old messages from device”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Creation of a group + + +

+ +
    +
  • Select New chat and then New group from the menu in the upper right corner or hit the corresponding button on Android/iOS.
  • +
  • On the following screen, select the group members and define a group name. You can also select a group avatar.
  • +
  • As soon as you write the first message in the group, all members are informed about the new group and can answer in the group (as long as you do not write a message in the group the group is invisible to the members).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + I have deleted myself by accident. + + +

+ +
    +
  • As you’re no longer a group member, you cannot add yourself again. +However, no problem, just ask any other group member in a normal chat to re-add you.
  • +
+ +

+ + + I do not want to receive the messages of a group any longer. + + +

+ +
    +
  • +

    Either delete yourself from the member list or delete the whole chat. +If you want to join the group again later on, ask another group member to add you again.

    +
  • +
  • +

    As an alternative, you can also “Mute” a group - doing so means you get all messages and +can still write, but are no longer notified of any new messages.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-client + + +

+ +

+ + + Can I use Delta Chat on multiple devices at the same time? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    On the second device, install Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    Transfer should start after a few seconds and during transfer both devices will show the progress. +Wait until it is finished on both devices.

    +
  • +
+ +

In contrast to many other messengers, after successful transfer, +both devices are completely independent. +One device is not needed for the other to work.

+ +

+ + + Troubleshooting + + +

+ +
    +
  • +

    Double-check both devices are in the same Wi-Fi or network

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Ensure there is enough storage on the destination device

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Are there any plans for introducing a Delta Chat Web Client? + + +

+ +
    +
  • There are no immediate plans but some preliminary thoughts.
  • +
  • There are 2-3 avenues for introducing a Delta Chat Web Client, but all are +significant work. For now, we focus on getting stable releases into all +app stores (Google Play/iOS/Windows/macOS/Linux repositories) as native apps.
  • +
  • If you need a Web Client, because you are not allowed to install software on +the computer you work with, you can use the portable Windows Desktop Client, +or the AppImage for Linux. You can find them on +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • +

    How many contacts are introduced by personally scanning a QR code?

    +
  • +
  • +

    Which versions of Delta Chat are being used?

    +
  • +
  • +

    What errors occur for users?

    +
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + I’m interested in the technical details. Can you tell me more? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Yes.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Can I reuse my existing private key? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Was Delta Chat independently audited for security vulnerabilities? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Miscellaneous + + +

+ +

+ + + Which permissions does Delta Chat need? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + How are Delta Chat developments funded? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Sometimes we receive one-time donations from private individuals. +For example, in 2021 a generous individual bank-wired us 4K EUR +with the subject “keep up the good developments!”. 💜 +We use such money to fund development gatherings or to care for ad-hoc expenses +that can not easily be predicted for, or reimbursed from, public funding grants. +Receiving more donations also helps us to become more independent and long-term viable +as a contributor community.

    + + +
  • +
  • +

    Last but by far not least, several pro-bono experts and enthusiasts contributed +and contribute to Delta Chat developments without receiving money, or only +small amounts. Without them, Delta Chat would not be where it is today, not +even close.

    +
  • +
+ +

The monetary funding mentioned above is mostly organized by merlinux GmbH in +Freiburg (Germany), and is distributed to more than a dozen contributors world-wide.

+ +

Please see Delta Chat Contribution channels +for both monetary and other contribution possibilities.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/es/help.html b/src/main/assets/help/es/help.html new file mode 100644 index 0000000000000000000000000000000000000000..974cca980fc3e0059a7957a522382df8b606e8ac --- /dev/null +++ b/src/main/assets/help/es/help.html @@ -0,0 +1,1657 @@ + + + + + +

+ + + ¿Qué es Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    Necesitas aceptar la solicitud antes de poder responder.

    +
  • +
  • +

    También puedes eliminarlo si no quieres chatear con ellos por ahora.

    +
  • +
  • +

    Si eliminas una solicitud, los mensajes futuros de ese contacto seguirán apareciendo como solicitud de mensaje, por lo que puedes cambiar de opinión. Si realmente no quieres recibir mensajes de esta persona, considera bloquearlos.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + ¿Delta Chat soporta envío de imágenes, videos, documentos y otros archivos? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    Para mejorar el rendimiento, las imágenes se optimizan y se envían en un tamaño más pequeño de forma predeterminada, pero puedes enviarla como un “archivo” para conservar la original.

    +
  • +
+ +

+ + + ¿Qué son los perfiles? ¿Cómo puedo cambiar entre ellos? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

En la primera instalación de Delta Chat se crea un primer perfil.

+ +

Después, puedes tocar la imagen de tu perfil en la esquina superior izquierda para Añadir perfiles +o para Cambiar perfiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

Quizás quieres aprender cómo se usa el mismo perfil en múltiples dispositivos.

+ +

+ + + ¿Quién ve mi foto de perfil? + + +

+ +
    +
  • +

    Puede agregar una foto de perfil en su configuración. Si escribe a sus contactos +o los agrega a través de un código QR, ellos lo verán automáticamente como su foto de perfil.

    +
  • +
  • +

    Por cuestiones de privacidad, nadie verá su foto de perfil hasta que les escriba un mensaje.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + ¿Qué significa fijar, mutear, archivar? + + +

+ +

Usa estas herramientas para organizar tus chats y mantener todo en su lugar:

+ +
    +
  • +

    Chats fijados siempre se mantienen al frente de tu listado. Puedes usarlos para acceder a tus chats preferidos de forma rápida o temporal para no olvidarte de tus cosas.

    +
  • +
  • +

    Chats muteados si no quieres recibir notificaciones de ellos. Chats muteados se mantienen en su lugar e inclusive puedes fijarlos.

    +
  • +
  • +

    Archivar chats si no deseas verlos en tu lista de chats. +Los chats archivados siguen siendo accesibles arriba de la lista de chats o a través de la búsqueda.

    +
  • +
  • +

    Cuando un chat archivado recibe un nuevo mensaje, a menos que esté silenciado, saldrá del archivo y volverá a aparecer en tu lista de chats. +Los chats silenciados permanecen archivados hasta que los desarchivas manualmente.

    +
  • +
+ +

Para archivar o fijar un chat, toque prolongadamente (Android), use el menú del chat (Android/Escritorio) o deslícese hacia la izquierda (iOS); +para silenciar un chat, use el menú del chat (Android/Escritorio) o el perfil del chat (iOS).

+ +

+ + + ¿Cómo funcionan los “Mensajes guardados”? + + +

+ +

Mensajes guardados es un chat que puedes utilizar para recordar y encontrar mensajes fácilmente.

+ +
    +
  • +

    En cualquier chat, mantén pulsado o haz clic con el botón derecho en un mensaje y selecciona Guardar

    +
  • +
  • +

    Los mensajes guardados se marcan con el símbolo + Saved icon +junto a la marca de tiempo

    +
  • +
  • +

    Después puedes abrir el chat “Mensajes guardados” - y allí verás los mensajes guardados. +Con pulsar a Arrow-right icon, +puedes volver al mensaje original en el chat original

    +
  • +
  • +

    Finalmente, también se puede utilizar “Mensajes guardados” para tomar notas personales - abre el chat, escribe algo, añade una foto o un mensaje de voz, etc.

    +
  • +
  • +

    Como los “mensajes guardados” se sincronizan, pueden ser muy útiles para transferir datos entre dispositivos.

    +
  • +
+ +

Los mensajes se quedan guardados, también si se han cambiado o están borrados - +Sea por el remitente, por la limpieza del dispositivo o por la desaparición de mensajes de otros chats.

+ +

+ + + ¿Qué significa el punto verde? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + ¿Qué significan las marcas que se muestran junto a los mensajes salientes? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Corregir errores y borrar mensajes después de enviar + + +

+ +
    +
  • +

    Se puede editar el texto de los mensajes después de enviarlos. +Para ello, mantenga pulsado o haz clic con el botón derecho en el mensaje y seleccione Editar +o Edit icon.

    +
  • +
  • +

    Si has enviado un mensaje accidentalmente +desde el mismo menú, seleccione Borrar y después Borrar para todos.

    +
  • +
+ +

Mientras que los mensajes editados tendrán la palabra “Editado” junto a la marca de tiempo, +los mensajes borrados serán eliminados sin un marcador en el chat. +No se envían notificaciones y no hay límite de tiempo.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + ¿Cómo funciona la desaparición de mensajes? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Tenga en cuenta que puede confiar en los mensajes que desaparecen +sólo mientras confíes en tus compañeros de chat; +compañeros de chat maliciosos pueden tomar fotos, +o guardar, copiar o reenviar mensajes antes de eliminarlos.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + ¿Qué pasa si activo “Borrar mensajes del dispositivo”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Creación de un grupo + + +

+ +
    +
  • Selecciona Nuevo chat y luego Nuevo grupo del menu en el sector superior derecho o toca en el botón correspondiente en Android/iOS.
  • +
  • En la siguiente pantalla selecciona a los miembros del grupo y define un nombre de grupo. Tambien puedes seleccionar un avatar de grupo.
  • +
  • Tan pronto escribas el primer mensaje en el grupo, todos los miembros serán informados sobre el nuevo grupo y podrán responder en él (mientras no escribas un mensaje será invisible para los miembros).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Me he eliminado por accidente. + + +

+ +
    +
  • Como ya no eres miembro del grupo, no puedes volver a agregarte. +Sin embargo, no hay problema, solo pídale a cualquier otro miembro del grupo en un chat normal que lo vuelva a agregar.
  • +
+ +

+ + + No quiero recibir más los mensajes de un grupo. + + +

+ +
    +
  • +

    Elimínate de la lista de miembros o elimina todo el chat. +Si desea unirse al grupo nuevamente más tarde, pídale a otro miembro del grupo que lo agregue nuevamente.

    +
  • +
  • +

    Como alternativa, también puede “silenciar” a un grupo, lo que significa que recibirá todos los mensajes y +aún puede escribir, pero ya no se le notifican nuevos mensajes.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Entrega de mensajes instantáneos y notificaciones Push + + +

+ +

+ + + ¿Qué son las Notificaciones Push? ¿Cómo puedo recibir mensajes instantáneos? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Las notificaciones push funcionan con todos los servidores de chatmail en

+ +
    +
  • +

    dispositivos iOS, por la integración de los servicios Push de Apple.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + ¿Están activadas las notificaciones Push en los dispositivos iOS? ¿Existe alguna alternativa? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + ¿Están habilitadas / son necesarias las notificaciones Push en los dispositivos Android? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + ¿Qué privadas son las notificaciones push de Delta Chat? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Así es como las aplicaciones Delta Chat realizan la entrega de Notificaciones Push:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Múltiples dispositivos + + +

+ +

+ + + ¿Puedo usar Delta Chat en varios dispositivos al mismo tiempo? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Asegurate que ambos dispositivos estén en la misma Wi-Fi o red

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    En el otro dispositivo, instala Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    La transferencia debería comenzar después de unos segundos y durante la transferencia ambos dispositivos mostrarán el progreso. +Espere hasta que termine en ambos dispositivos.

    +
  • +
+ +

A diferencia de muchas otras aplicaciones de mensajería, después de una transferencia exitosa, +ambos dispositivos son completamente independientes. +No es necesario un dispositivo para que el otro funcione.

+ +

+ + + Solución de problemas + + +

+ +
    +
  • +

    Vuelve a verificar que ambos dispositivos estén en la misma Wi-Fi o red

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Asegurate que haya suficiente espacio en el dispositivo destino

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Transferencia manual + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + ¿Tienen planeado crear un cliente web de Delta Chat? + + +

+ +
    +
  • No hay planes inmediatos, pero sí algunas ideas preliminares.
  • +
  • Hay 2-3 vías para introducir un cliente web de Delta Chat, pero todas son +trabajo significativo. Por ahora, nos centramos en conseguir versiones estables en todas las +tiendas de aplicaciones (Google Play/iOS/Windows/macOS/Linux repositorios) como aplicaciones nativas.
  • +
  • Si necesitas un cliente web, porque no tienes permiso para instalar software en +el ordenador con que trabajas, puedes utilizar el cliente portátil Windows Desktop Client +o el AppImage para Linux. Puedes encontrarlos en +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Estoy interesado en los detalles técnicos. ¿Pueden decirme más? + + +

+ + + +

+ + + Cifrado y seguridad + + +

+ +

+ + + ¿Qué estándares se utilizan para el cifrado de extremo a extremo? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

Para obtener una discusión más detallada sobre “cifrado de extremo a extremo garantizado”, por favor consulta los protocolos Secure-Join y lee específicamente sobre “Grupos Verificados”, el término técnico de lo que aquí se llama chats “marcados con una estampilla verde” o “cifrados de extremo a extremo garantizado”.

+ +

+ + + ¿Los adjuntos (imágenes, archivos, audio, etc.) están cifrados de extremo a extremo? + + +

+ +

Yes.

+ +

Cuando hablamos de un mensaje “cifrado de extremo a extremo”, +siempre nos referimos a que todo el mensaje está cifrado, +incluyendo todos los archivos adjuntos +y los metadatos de los archivos adjuntos, como los nombres de archivo.

+ +

+ + + ¿Es OpenPGP seguro? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + ¿Es Delta Chat vulnerable a EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + ¿Cómo Delta Chat protege los metadatos en los mensajes? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + ¿Cómo proteger los metadatos y los contactos cuando se incauta un dispositivo? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + ¿Soporta Delta Chat Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

Puede verificar manualmente el estado de cifrado de extremo a extremo en el diálogo “Cifrado” (perfil de usuario en Android/iOS o hacer clic derecho en el elemento de lista de chat de un usuario en el escritorio). Delta Chat muestra dos huellas digitales allí. Si las mismas huellas digitales aparecen en su propio dispositivo y en el dispositivo de su contacto, la conexión es segura.

+ +

+ + + ¿Puedo reutilizar mi clave privada existente? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + ¿Se auditó Delta Chat de forma independiente en busca de vulnerabilidades de seguridad? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Miscelaneo + + +

+ +

+ + + ¿Qué permisos necesita Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + ¿Cómo se financia el desarrollo de Delta Chat? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    El proyecto de la UE NEXTLEAP financió la investigación +e implementación de grupos verificados y protocolos de contacto +en 2017 y 2018 y también ayudó a integrar el cifrado de extremo a extremo +a través de Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Sometimes we receive one-time donations from private individuals. +For example, in 2021 a generous individual bank-wired us 4K EUR +with the subject “keep up the good developments!”. 💜 +We use such money to fund development gatherings or to care for ad-hoc expenses +that can not easily be predicted for, or reimbursed from, public funding grants. +Receiving more donations also helps us to become more independent and long-term viable +as a contributor community.

    + + +
  • +
  • +

    Por último, pero no por ello menos importante, varios expertos pro-bono y entusiastas contribuyeron +y contribuyen a los desarrollos de Delta Chat sin recibir dinero, o sólo +pequeñas cantidades. Sin ellos, Delta Chat no estaría donde está hoy, ni +siquiera cerca.

    +
  • +
+ +

La financiación monetaria mencionada anteriormente es principalmente organizada por merlinux GmbH en Freiburg (Alemania), y se distribuye a más de una docena de colaboradores en todo el mundo.

+ +

Por favor, consulta Canales de contribución de Delta Chat para conocer las posibilidades de contribución tanto monetarias como otras.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/fr/help.html b/src/main/assets/help/fr/help.html new file mode 100644 index 0000000000000000000000000000000000000000..bca19599ab1a0afce57b044a58a11ee003ec4e31 --- /dev/null +++ b/src/main/assets/help/fr/help.html @@ -0,0 +1,1619 @@ + + + + + +

+ + + Qu’est-ce que Delta Chat ? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    Vous devez d’abord accepter pour pouvoir répondre au message.

    +
  • +
  • +

    Vous pouvez également choisir de supprimer l’invitation si vous ne souhaitez pas discuter avec cet inconnu maintenant.

    +
  • +
  • +

    Si vous supprimez une invitation, les futurs messages de ce contact apparaîtront +de nouveau comme des invitations, de sorte que vous pouvez changer d’avis par la suite. Si vous ne voulez vraiment pas +recevoir de messages de cette personne, nous vous conseillons de la bloquer.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Delta Chat prend-il en charge les images, vidéos et autres pièces jointes ? + + +

+ +
    +
  • +

    Oui. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    Pour améliorer les performances, les images sont redimensionnées et envoyées en taille réduite par défaut ; mais vous pouvez les envoyer en tant que “fichier” pour en conserver la taille originale.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Qui peut voir ma photo de profil ? + + +

+ +

Dans les paramètres vous pouvez ajouter une photo de profil. Si vous écrivez à vos contacts ou que vous les ajoutez via le QR code, ils la verront automatiquement comme votre photo de profil.

+ +
    +
  • Pour des raisons de confidentialité, personne ne peut voir votre photo de profil sans que vous ayez d’abord entamé une discussion.
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + Que signifient “épingler”, “sourdine” et “archiver” ? + + +

+ +

Ces options vous permettent d’organiser et trier vos discussions :

+ +
    +
  • +

    Les discussions épinglées restent en haut de votre liste de discussions. Vous pouvez ainsi retrouver plus rapidement vos discussions favorites ou éviter d’oublier des messages importants.

    +
  • +
  • +

    Utilisez la sourdine pour les discussions dont vous ne voulez pas recevoir les notifications. Les discussions en sourdine figurent toujours dans votre liste et peuvent aussi être les épinglées.

    +
  • +
  • +

    Archivez les discussions si vous ne voulez plus les voir apparaître dans votre liste de discussions. +Les discussions archivées restent accessibles au-dessus de la liste de discussions ou via la recherche.

    +
  • +
  • +

    Lorsqu’un nouveau message est envoyé sur une discussion que vous avez archivée, et que vous n’avez pas mise en sourdine, la discussion sort des archives et reprend sa place dans votre liste de discussions. +Les discussions en sourdine restent archivées tant que vous ne les désarchivez pas manuellement.

    +
  • +
+ +

Pour archiver ou épingler une discussion, faites un appui long sur la discussion concernée (Android), ouvrez le menu de la conversation (Android/application de bureau), ou balayez vers la gauche (iOS) ; +pour mettre une discussion en sourdine, ouvrez le menu de la conversation (Android/application de bureau) ou le profil de la discussion (iOS).

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + Que signifie le point vert ? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Que signifient les coches affichées à côté des messages sortants ? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + Que se passe-t-il si j’active l’option “Supprimer les anciens messages de l’appareil” ? + + +

+ +
    +
  • Vous pouvez choisir de supprimer automatiquement les anciens messages pour libérer de l’espace de stockage sur votre appareil.
  • +
  • Pour activer cette option, ouvrez les paramètres des “Discussions et fichiers multimédias” et cliquez sur “Supprimer les anciens messages de l’appareil”. Vous pouvez définir le délai après lequel tous les messages seront supprimés de votre appareil, parmi plusieurs choix allant de “Immédiatement” à “Après 1 année”.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Création d’un groupe + + +

+ +
    +
  • Sélectionnez Nouvelle discussion puis Nouveau groupe dans le menu à trois points situé en haut à droite de la fenêtre ou son équivalent sous Android et iOS.
  • +
  • Sur l’écran suivant, sélectionnez Ajouter des participants et choisissez un Nom du groupe. Vous pouvez aussi choisir une image de groupe.
  • +
  • Lorsque vous enverrez le premier message dans le groupe, tous les membres en seront informés et pourront répondre. Le groupe est invisible aux autres membres si vous n’écrivez pas de premier message.
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + J’ai quitté un groupe par accident. + + +

+ +
    +
  • Comme vous n’êtes plus membre du groupe, vous ne pouvez pas vous y ajouter vous-même. +Contactez n’importe quel autre membre de ce groupe dans une discussion directe pour lui demander de vous y ré-inviter.
  • +
+ +

+ + + Je ne souhaite plus recevoir les messages d’un groupe. + + +

+ +
    +
  • +

    Supprimez-vous de la liste des membres ou supprimez la discussion entière. +Si souhaitez rejoindre le groupe plus tard, demandez à un autre membre du groupe de vous ré-inviter.

    +
  • +
  • +

    Vous pouvez également mettre un groupe en “Sourdine” : vous recevrez tous les messages et pourrez toujours écrire, mais n’aurez plus les notifications des nouveaux messages.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-client + + +

+ +

+ + + Puis-je utiliser Delta Chat sur plusieurs appareils en même temps? + + +

+ +

Oui. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    Sur le premier appareil, allez dans Paramètres → Ajouter un deuxième appareil, déverrouillez l’écran si nécessaire, et patientez un peu jusqu’à ce qu’un code QR s’affiche.

    +
  • +
  • +

    Sur le deuxième appareil, installez Delta Chat.

    +
  • +
  • +

    Sur le deuxième appareil, ouvrez Delta Chat, sélectionnez Ajouter comme deuxième appareil, et scannez le code QR du premier appareil.

    +
  • +
  • +

    Le transfert devrait commencer quelques secondes après et, pendant l’opération, les deux appareils affichent l’état d’avancement. +Patientez jusqu’à ce que le transfert soit terminé sur les deux appareils.

    +
  • +
+ +

Contrairement à de nombreuses autres messageries, une fois le transfert terminé, les deux appareils sont complètement indépendants. +L’un n’a pas besoin de l’autre pour pouvoir fonctionner.

+ +

+ + + Dépannage + + +

+ +
    +
  • +

    Vérifier à nouveau que les deux appareils sont sur le même réseau ou le même Wi-Fi.

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Il se peut que votre système dispose d’un “pare-feu personnalisé”, +source bien connue de dysfonctionnements (en particulier sur Windows). +Désactivez le pare-feu personnalisé sur chaque appareil pour Delta Chat et réessayez.

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Assurez-vous de disposer d’un espace de stockage suffisant sur l’appareil de destination.

    +
  • +
  • +

    Une fois que le transfert a commencé, assurez-vous que les appareils restent actifs et ne se mettent pas en veille. +Ne quittez pas Delta Chat ! +(Nous faisons de notre mieux pour que l’application fonctionne en arrière-plan, mais les systèmes ont une fâcheuse tendance à tuer les applis.)

    +
  • +
  • +

    Si Delta Chat est déjà connecté sur l’appareil de destination. +Vous pouvez utiliser plusieurs comptes par appareil : ajoutez un nouveau compte.

    +
  • +
  • +

    Si les problèmes persistent, ou si vous ne pouvez pas scanner de code QR, essayez la méthode de transfert manuel décrite ci-dessous.

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

Recourez à cette méthode uniquement si les instructions ci-dessus pour “Ajouter un deuxième appareil” ont échoué.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Le lancement d’un client Web Delta Chat est-il prévu ? + + +

+ +
    +
  • Il n’y a pas de plans immédiats mais quelques idées préliminaires.
  • +
  • Il y a 2-3 façons d’introduire un client Web Delta Chat, mais toutes représentent + un travail conséquent. Pour l’instant, nous nous concentrons à sortir des versions stables dans tous les magasins d’applications (Google Play/iOS/Windows/macOS/centre de paquets Linux) en tant qu’applications natives.
  • +
  • Si vous avez besoin d’un client Web parce que vous n’êtes pas autorisé à installer des logiciels sur l’ordinateur sur lequel vous travaillez, vous pouvez utiliser le Client Portable pour bureaux Windows ou l’AppImage pour Linux. Vous pouvez les trouver sur +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Les détails techniques m’intéressent. Pouvez-vous m’en dire plus ? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Oui.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Puis-je ré-utiliser ma clé privée existante ? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Est-ce qu’un audit indépendant des failles de sécurité a été réalisé sur Delta Chat ? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    Début 2023, nous avons réparé les failles de sécurité et de confidentialité de la fonctionnalité “partage d’appli web dans une discussion” liées à des dysfonctionnements en mode bac à sable, en particulier avec Chromium. Après quoi, nous avons soumis Delta Chat à un nouvel audit de sécurité indépendant par Cure53, puis effectué les réparations de toutes les failles découvertes pour la version 1.36 de nos applications, publiée en avril 2023. +Vous trouverez ici un article de fond complet à propos de la sécurité du chiffrement de bout-en-bout sur internet.

    +
  • +
  • +

    Début 2023, Cure53 a analysé le chiffrement d’acheminement des connexions réseau de Delta Chat et testé une configuration de serveur de courriel reproductible, telle que recommandée sur ce site. +Vous trouverez plus d’informations sur cet audit sur notre blog ou dans le rapport complet ici.

    +
  • +
  • +

    En 2020, Include Security a analysé les bibliothèques principales Rust de Delta Chat, ainsi que ses bibliothèques IMAP, SMTP et TLS. +Aucun problème grave ou critique n’a été découvert. +Le rapport a tout de même révélé quelques vulnérabilités de gravité moyenne, qui ne représentent pas une menace en elles-mêmes pour les utilisateurs et les utilisatrices de Delta Chat, car elles dépendent de l’environnement dans lequel Delta Chat est utilisé. +Pour des raison de compatibilité et de facilité d’utilisation, nous ne pouvons pas les pallier toutes et avons préféré fournir des préconisations de sécurité aux personnes exposées. +Le rapport complet est consultable ici.

    +
  • +
  • +

    En 2019, Include Security a analysé les bibliothèques PGP et +RSA de Delta Chat. +Aucune faille critique n’a été trouvée, mais deux failles sévères ont été identifiées et immédiatement réparées par nos soins. +Une faille de gravité moyenne ainsi que quelques failles de gravité moindre ont également été découvertes, sans qu’il soit toutefois possible de les exploiter dans le fonctionnement de Delta Chat. +Nous en avons néanmoins réparées certaines depuis le rapport d’audit. +Le rapport complet est consultable ici.

    +
  • +
+ +

+ + + Divers + + +

+ +

+ + + De quelles autorisations Delta Chat a-t-il besoin? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Comment est financé le développement de Delta Chat ? + + +

+ +

Delta Chat ne reçoit par de fonds en capital-risque, n’est pas endetté et ne subit aucune pression pour générer de gros profits ou vendre ses utilisateurs et utilisatrices - en même temps que leurs amis et leur famille - à des annonceurs (ou pire). +Nous préférons utiliser des fonds provenant d’institutions publiques, jusqu’à présent basées en Europe ou aux États-Unis, pour soutenir nos efforts de développement d’un système de messagerie diversifié et décentralisé, basé sur les contributions de la communauté du libre et de l’open-source.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    L’association Open Technology Fund nous a octroyé une première subvention en 2018/2019 (de 200.000$ environ), grâce à laquelle nous avons pu apporter des améliorations majeures à l’application Android et publier une première version Beta de l’application de bureau. Elle nous a aussi permis d’ancrer notre recherche UX de développement de fonctionnalités dans des contextes de droits humains.
    +À ce sujet, vous pouvez consulter notre rapport en anglais “Needfinding and UX report”. +La seconde subvention de 2019/2020 (environ 300.000$) nous a permis de publier des version iOS de Delta Chat, de convertir notre bibliothèque principale en Rust et de créer de nouvelles fonctionnalités pour toutes les plateformes.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Nous recevons parfois des dons ponctuels de la part de personnes privées. + En 2021 par exemple, une généreuse personne nous a envoyé 4000€ par virement bancaire, avec l’intitulé “continuez votre super travail de développement !”. 💜 + Nous utilisons l’argent de ces dons pour financer des rencontres entre développeurs et développeuses ou pour des dépenses ponctuelles difficiles à anticiper ou à rembourser avec des subventions publiques. +Recevoir plus de dons aide notre communauté de contributrices et contributeurs à devenir plus indépendante et à rester viable sur le long terme.

    + + +
  • +
  • +

    Dernier point, mais certainement pas des moindres : quelques personnes passionnées et expertes ont apporté, et apportent toujours, leur contribution bénévole au développement de Delta Chat sans contrepartie financière - ou seulement de petites sommes. Sans leur énergie, Delta Chat n’en serait pas là aujourd’hui… et de très loin.

    +
  • +
+ +

Les financements mentionnés précédemment sont gérés principalement par merlinux GmbH à Fribourg (Allemagne) et redistribués à plus d’une douzaine de contributeurs et contributrices du monde entier.

+ +

N’hésitez pas à consulter les Canaux de contribution à Delta Chat Contribution pour en savoir plus sur les différentes manières de contribuer, financières ou non.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/go-to-original.png b/src/main/assets/help/go-to-original.png new file mode 100644 index 0000000000000000000000000000000000000000..88fcbe13eadae1fc984439956fc57f50c7f7a4b9 Binary files /dev/null and b/src/main/assets/help/go-to-original.png differ diff --git a/src/main/assets/help/green-checkmark.png b/src/main/assets/help/green-checkmark.png new file mode 100644 index 0000000000000000000000000000000000000000..8f42caaf2e2366baf8c37025d78233098233c1cc Binary files /dev/null and b/src/main/assets/help/green-checkmark.png differ diff --git a/src/main/assets/help/green-dot.png b/src/main/assets/help/green-dot.png new file mode 100644 index 0000000000000000000000000000000000000000..6419864b32be0ccc95800172324be64cf784539b Binary files /dev/null and b/src/main/assets/help/green-dot.png differ diff --git a/src/main/assets/help/help.css b/src/main/assets/help/help.css new file mode 100644 index 0000000000000000000000000000000000000000..b2770e33cdf8b1cf42a0ea67ee698ee6774229cb --- /dev/null +++ b/src/main/assets/help/help.css @@ -0,0 +1,36 @@ +html { + color-scheme: dark light; +} + +a { + text-decoration: none; +} + +h2, h3, h4 { + margin-top: 2rem; +} + +ul#top { + list-style-type: none; + margin: 1.2rem 0 0 0; + padding: 0; +} + +ul#top > li { + font-weight: bold !important; + font-size: 1.1rem !important; +} + +ul#top li { + margin-top: .7rem; + margin-bottom: .7rem; + font-weight: normal; + font-size: 1rem; +} + + +/* ---------- colors ---------- */ + +a { + color: #0078f1; +} diff --git a/src/main/assets/help/id/help.html b/src/main/assets/help/id/help.html new file mode 100644 index 0000000000000000000000000000000000000000..efc132aedb97a6a6ec45779817ca76996a46d571 --- /dev/null +++ b/src/main/assets/help/id/help.html @@ -0,0 +1,1668 @@ + + + + + +

+ + + Apa itu Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    You need to accept the request before you can reply.

    +
  • +
  • +

    You can also delete it if you don’t want to chat with them for now.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Apakah Delta Chat mendukung gambar, vidio dan lampiran lainnya? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Siapa yang dapat melihat Foto Profil saya? + + +

+ +
    +
  • +

    Anda dapat menambahkan gambar profil di pengaturan Anda. Jika Anda menulis ke kontak Anda +atau menambahkannya melalui kode QR, mereka secara otomatis melihatnya sebagai gambar profil Anda.

    +
  • +
  • +

    Untuk alasan kerahasiaan, tidak ada satupun yang dapat melihat Foto Profil anda hingga anda menulis +sebuah pesan kepada mereka.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + What do Pinning, Muting and Archiving mean? + + +

+ +

Use these tools to organize your chats and keep everything in its place:

+ +
    +
  • +

    Pinned chats always stay atop of the chat list. You can use them to access your most loved chats quickly or temporarily to not forget about things.

    +
  • +
  • +

    Mute chats if you do not want to get notifications for them. Muted chats stay in place and you can also pin a muted chat.

    +
  • +
  • +

    Archive chats if you do not want to see them in your chat list any longer. +Archived chats remain accessible above the chat list or via search.

    +
  • +
  • +

    When an archived chat gets a new message, unless muted, it will pop out of the archive and back into your chat list. +Muted chats stay archived until you unarchive them manually.

    +
  • +
+ +

To use the functions, long tap or right click a chat in the chat list.

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + What does the green dot mean? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + What do the ticks shown beside outgoing messages mean? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + What happens if I turn on “Delete old messages from device”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Pembuatan grup + + +

+ +
    +
  • Select New chat and then New group from the menu in the upper right corner or hit the corresponding button on Android/iOS.
  • +
  • On the following screen, select the group members and define a group name. You can also select a group avatar.
  • +
  • As soon as you write the first message in the group, all members are informed about the new group and can answer in the group (as long as you do not write a message in the group the group is invisible to the members).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + I have deleted myself by accident. + + +

+ +
    +
  • As you’re no longer a group member, you cannot add yourself again. +However, no problem, just ask any other group member in a normal chat to re-add you.
  • +
+ +

+ + + I do not want to receive the messages of a group any longer. + + +

+ +
    +
  • +

    Either delete yourself from the member list or delete the whole chat. +If you want to join the group again later on, ask another group member to add you again.

    +
  • +
  • +

    As an alternative, you can also “Mute” a group - doing so means you get all messages and +can still write, but are no longer notified of any new messages.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-client + + +

+ +

+ + + Can I use Delta Chat on multiple devices at the same time? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    On the second device, install Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    Transfer should start after a few seconds and during transfer both devices will show the progress. +Wait until it is finished on both devices.

    +
  • +
+ +

In contrast to many other messengers, after successful transfer, +both devices are completely independent. +One device is not needed for the other to work.

+ +

+ + + Troubleshooting + + +

+ +
    +
  • +

    Double-check both devices are in the same Wi-Fi or network

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Ensure there is enough storage on the destination device

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Are there any plans for introducing a Delta Chat Web Client? + + +

+ +
    +
  • There are no immediate plans but some preliminary thoughts.
  • +
  • There are 2-3 avenues for introducing a Delta Chat Web Client, but all are +significant work. For now, we focus on getting stable releases into all +app stores (Google Play/iOS/Windows/macOS/Linux repositories) as native apps.
  • +
  • If you need a Web Client, because you are not allowed to install software on +the computer you work with, you can use the portable Windows Desktop Client, +or the AppImage for Linux. You can find them on +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + I’m interested in the technical details. Can you tell me more? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Yes.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Can I reuse my existing private key? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Was Delta Chat independently audited for security vulnerabilities? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Miscellaneous + + +

+ +

+ + + Izin apa yang dibutuhkan Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + How are Delta Chat developments funded? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Terkadang kami menerima sumbangan satu kali dari perorangan. +Misalnya, pada tahun 2021, seorang dermawan perorangan mengirimi kami dana sebesar 4K EUR +dengan subjek “ikuti perkembangan yang baik!”. 💜 +Kami menggunakan uang tersebut untuk mendanai pertemuan pengembangan atau untuk biaya ad-hoc +yang tidak dapat dengan mudah diprediksi, atau diganti dari hibah dana publik. +Menerima lebih banyak donasi juga membantu kami untuk menjadi lebih mandiri dan +sebagai komunitas penyumbang.

    + + +
  • +
  • +

    Terakhir, beberapa ahli dan penggemar pro-bono turut berkontribusi +dan berkontribusi pada pengembangan Delta Chat tanpa menerima uang, atau hanya +hanya dalam jumlah kecil. Tanpa mereka, Delta Chat tidak akan berada di tempat seperti sekarang ini, bahkan tidak +bahkan tidak mendekati.

    +
  • +
+ +

Pendanaan moneter yang disebutkan di atas sebagian besar dikelola oleh merlinux GmbH di +Freiburg (Jerman), dan didistribusikan ke lebih dari selusin kontributor di seluruh dunia.

+ +

Silakan lihat Saluran Kontribusi Delta Chat +untuk mengetahui kemungkinan kontribusi moneter dan kontribusi lainnya.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/it/help.html b/src/main/assets/help/it/help.html new file mode 100644 index 0000000000000000000000000000000000000000..1ace4a16ccd8ee2c6cd3ad6a33ce8bcb820b4b69 --- /dev/null +++ b/src/main/assets/help/it/help.html @@ -0,0 +1,1662 @@ + + + + + +

+ + + Cos’è Delta Chat? + + +

+ +

Delta Chat è un’app di messaggistica istantanea affidabile, decentralizzata e sicura, +disponibile per piattaforme mobili e desktop.

+ + + +

+ + + Come posso trovare persone con cui chattare? + + +

+ +

Innanzitutto, tieni presente che Delta Chat è un servizio di messaggistica privato. +Non c’è accesso pubblico, sei tu a decidere quali sono i tuoi contatti.

+ +
    +
  • +

    Se ti trovi faccia a faccia con un amico o un familiare, +tocca l’icona del Codice QR +nella schermata principale.
    +Chiedi al tuo interlocutore di scansionare l’immagine QR +con la sua app Delta Chat.

    +
  • +
  • +

    Per impostare un contatto remoto, +dalla stessa schermata, +clicca su “Copia” o “Condividi” e invia il link di invito +tramite un’altra chat privata.

    +
  • +
+ +

Ora attendi che la connessione venga stabilita.

+ +
    +
  • +

    Se entrambe le parti sono online, vedranno presto una chat +e potranno iniziare a inviare messaggi in modo sicuro.

    +
  • +
  • +

    Se una delle due parti è offline o ha una rete scadente, +la possibilità di chattare viene ritardata finché la connettività non viene ripristinata.

    +
  • +
+ +

Congratulazioni! +Ora utilizzerai automaticamente la crittografia end-to-end con questo contatto. +Se vi aggiungete a vicenda a gruppi, la crittografia end-to-end verrà stabilita tra tutti i membri.

+ +

+ + + Perché una chat è contrassegnata come “Richiesta”? + + +

+ +

Essendo un messenger privato, +solo gli amici e i familiari con cui condividi il tuo codice QR o il link di invito possono scriverti.

+ +

I tuoi amici potrebbero condividere i tuoi contatti con altri amici; ciò apparirà come una richiesta.

+ +
    +
  • +

    È necessario accettare la richiesta prima di poter rispondere.

    +
  • +
  • +

    Si può anche cancellare il messaggio se non si vuole più chattare con esso.

    +
  • +
  • +

    Se si elimina una richiesta, i futuri messaggi di quel contatto continueranno a essere visualizzati come richieste di contatto +in modo da poter cambiare idea. Se non si vuole davvero ricevere +messaggi da questa persona, prendete in considerazione la possibilità di bloccarla.

    +
  • +
+ +

+ + + Come posso mettere in contatto due miei amici? + + +

+ +

Collega il primo contatto alla chat del secondo utilizzando Paperclip Pulsante Allegato → Contatto. +Puoi anche aggiungere un breve messaggio di presentazione.

+ +

Il secondo contatto riceverà una scheda +e potrà toccarla per iniziare a chattare con il primo contatto.

+ +

+ + + Delta Chat supporta immagini, video e altri allegati? + + +

+ +
    +
  • +

    Sì. Immagini, video, files, messaggi vocali ecc. possono essere inviati utilizzando Paperclip Allegato- +o Microphone pulsanti Messaggio Vocale

    +
  • +
  • +

    Per le prestazioni, le immagini sono ottimizzate e inviate in dimensioni inferiori per impostazione predefinita, ma è possibile inviarle come “file” per preservare l’originale.

    +
  • +
+ +

+ + + Cosa sono i profili? Come posso passare dall’uno all’altro? + + +

+ +

Un profilo è costituito da un nome, un’immagine e alcune informazioni aggiuntive per la crittografia dei messaggi. +Un profilo è memorizzato solo sui tuoi dispositivo(i) +e utilizza il server solo per inoltrare i messaggi.

+ +

Alla prima installazione di Delta Chat viene creato un primo profilo.

+ +

Successivamente, puoi toccare l’immagine del tuo profilo nell’angolo in alto a sinistra per Aggiungere Profili +o Cambiare Profili.

+ +

Potresti voler utilizzare profili separati per le attività politiche, familiari o lavorative.

+ +

Potresti anche voler imparare come utilizzare lo stesso profilo su più dispositivi.

+ +

+ + + Chi vede la mia immagine del profilo? + + +

+ +
    +
  • +

    Puoi aggiungere un’immagine del profilo nelle tue impostazioni. Se scrivi ai tuoi contatti +o li aggiungi tramite codice QR, la vedranno automaticamente come immagine del tuo profilo.

    +
  • +
  • +

    Per motivi di privacy, nessuno vede la tua immagine del profilo finché non scrivi un +messaggio a loro.

    +
  • +
+ +

+ + + Posso impostare una Biografia/Stato con Delta Chat? + + +

+ +

Sì, +puoi farlo in Impostazioni → Profilo → Biografia. +Una volta inviato un messaggio a un contatto, +lo vedrà quando visualizzerà i tuoi dati di contatto.

+ +

+ + + Cosa significa Fissare, Silenziare, Archiviare? + + +

+ +

Usa questi strumenti per organizzare le tue chat e tenere tutto in ordine:

+ +
    +
  • +

    Chat fissate restano sempre in cima all’elenco. Puoi usarlo per accedere velocemente alle tue chat preferite o temporaneamente per non dimenticare alcune cose.

    +
  • +
  • +

    Silenzia chat se non vuoi ricevere notifiche da queste. Le chat silenziate restano al loro posto e puoi anche fissare una chat silenziata.

    +
  • +
  • +

    Archivia chats se non vuoi più vederle nel tuo elenco chat. +Le chat archiviate rimangono accessibili sopra l’elenco delle chat o tramite la ricerca.

    +
  • +
  • +

    Quando una chat archiviata riceve un nuovo messaggio, a meno che non sia silenziata, salterà fuori dall’archivio e tornerà nell’elenco delle chat. +Le chat silenziate restano archiviate fino a che non le estrai manualmente.

    +
  • +
+ +

Per archiviare o fissare una chat, premi a lungo (Android), usa il menu della chat (Android/Desktop) o striscia verso sinistra (iOS); +per silenziare una chat, usa il menu della chat (Android/Desktop) o il profilo della chat (iOS).

+ +

+ + + Come funziona “Messaggi Salvati”? + + +

+ +

Messaggi Salvati è una chat che puoi usare per ricordare e trovare facilmente i messaggi.

+ +
    +
  • +

    in qualunque chat, premi a lungo o click destro sul messaggio e seleziona Salva

    +
  • +
  • +

    I messaggi salvati sono marcati col simbolo +Saved icon +accanto all’ora d’invio

    +
  • +
  • +

    Successivamente, apri la chat “Messaggi Salvati” - e vedrai là i messaggi che hai salvato. +ToccandoArrow-right icon, +puoi tornare al messaggio originale nella chat di provenienza.

    +
  • +
  • +

    Infine, puoi anche usare “Messaggi Salvati” per prendere appunti - apri la chat, digita qualcosa, aggiungi una foto o un messaggio vocale, ecc.

    +
  • +
  • +

    Dato che i “Messaggi Salvati” sono sincronizzati, possono essere molto comodi per trasferire dati tra i dispositivi

    +
  • +
+ +

I messaggi restano salvati anche se vengono modificati o eliminati - +che sia dal mittente, ripulendo il dispositivo o per i messaggi a scomparsa di altre chat.

+ +

+ + + Cosa significa il punto verde? + + +

+ +

A volte puoi vedere un punto verde +accanto all’avatar di un contatto. +Significa che sono stati visti di recente negli ultimi 10 minuti, +ad es. perché ti hanno inviato un messaggio o una conferma di lettura.

+ +

Quindi questo non è uno stato online in tempo reale +e anche gli altri non sempre vedranno che sei “online”.

+ +

+ + + Cosa significano i segni di spunta visualizzati accanto ai messaggi in uscita? + + +

+ +
    +
  • +

    Un segno di spunta +significa che il messaggio è stato inviato correttamente al tuo fornitore.

    +
  • +
  • +

    Due spunte +significa che almeno il dispositivo di un destinatario +ha segnalato di aver ricevuto il messaggio.

    +
  • +
  • +

    I destinatari potrebbero aver disattivato le conferme di lettura, +quindi anche se vedi solo un segno di spunta, il messaggio potrebbe essere stato letto.

    +
  • +
  • +

    Al contrario, due spunte non significano automaticamente +che un essere umano abbia letto o compreso il messaggio ;)

    +
  • +
+ +

+ + + Correggi gli errori e cancella i messaggi dopo averli inviati + + +

+ +
    +
  • +

    Puoi modificare il testo dei tuoi messaggi già inviati. +Per farlo, premi a lungo o click destro sul messaggio e seleziona Edit +o Edit icon.

    +
  • +
  • +

    Se hai inviato un messaggio accidentalmente, +dallo stesso menu, seleziona Elimina e quindi Cancella per Tutti.

    +
  • +
+ +

Una volta modificati i messaggi avranno scritto “Modificato” accanto all’orario d’invio, +i messaggi eliminati saranno rimossi senza alcun avviso in chat. +Non vengono inviate notifiche e non c’è limite di tempo.

+ +

Nota che il messaggio originale potrebbe essere ancora sui dispositivi dei membri della chat +che avrebbero già potuto rispondere, inoltrare, salvare, scattare una schermata o copiare il messaggio in altri modi.

+ +

+ + + Come funzionano i messaggi a scomparsa? + + +

+ +

Puoi attivare i “messaggi a scomparsa” +nelle impostazioni di una chat, +in alto a destra nella finestra della chat, +selezionando un intervallo di tempo +compreso tra 5 minuti e 1 anno.

+ +

Finché l’impostazione non viene disattivata nuovamente, +l’app Delta Chat di ciascun membro della chat si occupa +di eliminare i messaggidopo l’intervallo di tempo selezionato. +L’intervallo di tempo inizia +quando il destinatario vede per la prima volta il messaggio in Delta Chat. +I messaggi vengono eliminati sia +sui server +che nell’app stessa.

+ +

Tieni presente che puoi fare affidamento sui messaggi che scompaiono +solo finché ti fidi dei tuoi partner di chat; +i partner di chat dannosi possono scattare foto, +o altrimenti salvare, copiare o inoltrare messaggi prima della cancellazione.

+ +

Oltre a ciò, +se uno dei partecipanti alla chat disinstalla Delta Chat, +i messaggi (comunque crittografati) potrebbero richiedere più tempo per essere eliminati dal server.

+ +

+ + + Cosa succede se attivo “Elimina Messaggi dal Dispositivo”? + + +

+ +
    +
  • Se si desidera risparmiare spazio sul dispositivo, è possibile scegliere di eliminare i vecchi +messaggi automaticamente.
  • +
  • Per attivarla, andare su “Elimina Messaggi dal Dispositivo” nelle impostazioni di “Chat e Media”. +È possibile impostare un intervallo di tempo compreso tra “Dopo 1 ora” e “Dopo 1 anno”; +in questo modo, tutti i messaggi saranno eliminati dal dispositivo non appena saranno +più vecchi di quel periodo.
  • +
+ +

+ + + Come posso eliminare il mio profilo chat? + + +

+ +

Se utilizzi più di un profilo chat, +puoi rimuoverne uno solo dal menu di selezione profilo in alto (su Android e iOS) +o dalla barra laterale con un clic destro (nell’app Desktop). +I profili chat vengono rimossi solo sul dispositivo in cui è stata attivata l’eliminazione. +I profili chat sugli altri dispositivi continueranno a funzionare correttamente.

+ +

Se utilizzi un singolo profilo chat predefinito, puoi semplicemente disinstallare l’app. +Ciò attiverà comunque l’eliminazione automatica di tutti i dati di indirizzo associati sul server di chatmail. +Per maggiori informazioni, consulta nine.testrun.org cancellazione dell’indirizzo +o la pagina corrispondente del server chatmail di 3e parti da te scelto.

+ +

+ + + Gruppi + + +

+ +

I gruppi consentono a più persone di chattare insieme in privato con uguali diritti.

+ +

Chiunque può +modificare il nome o l’avatar del gruppo, +aggiungere o rimuovere membri, +impostare messaggi che scompaiono +ed eliminare i propri messaggi dai dispositivi di tutti i membri.

+ +

Poiché tutti i membri hanno gli stessi diritti, i gruppi funzionano meglio se formati da amici e familiari fidati.

+ +

+ + + Creazione di un gruppo + + +

+ +
    +
  • Seleziona Nuova chat e poi Nuovo gruppo dal menu nell’angolo in alto a destra o premi il pulsante corrispondente su Android/iOS.
  • +
  • Nella schermata successiva, seleziona i membri del gruppo e definisci un nome del gruppo. Puoi anche selezionare un avatar di gruppo.
  • +
  • Non appena scrivi il primo messaggio nel gruppo, tutti i membri vengono informati del nuovo gruppo e possono rispondere nel gruppo (finché non scrivi un messaggio nel gruppo il gruppo è invisibile ai membri).
  • +
+ +

+ + + Aggiungi e rimuovi membri + + +

+ +
    +
  • +

    Tutti i membri del gruppo hanno gli stessi diritti. +Per questo motivo, tutti possono eliminare qualsiasi membro o aggiungerne di nuovi.

    +
  • +
  • +

    Per aggiungere o eliminare membri, tocca il nome del gruppo nella chat e seleziona il membro da aggiungere o rimuovere.

    +
  • +
  • +

    Se il membro non è ancora nella tua lista contatti, ma è faccia a faccia con te, +dalla stessa schermata, mostra un codice QR. +Chiedi al tuo interlocutore di scansionare l’immagine QR con la sua app Delta Chat toccando + nella schermata principale.

    +
  • +
  • +

    Per aggiungere un membro da remoto, +clicca su “Copia” o “Condividi” e invia il link di invito +tramite un’altra chat privata al nuovo membro.

    +
  • +
+ +

Il codice QR e il link di invito possono essere utilizzati per aggiungere più membri. +Tuttavia, poiché i gruppi sono destinati a persone fidate, evita di condividerli pubblicamente.

+ +

+ + + Mi sono cancellato per sbaglio. + + +

+ +
    +
  • Poiché non sei più un membro del gruppo, non puoi aggiungerti di nuovo. +Tuttavia, nessun problema, chiedi a qualsiasi altro membro del gruppo in una normale chat di aggiungerti nuovamente.
  • +
+ +

+ + + Non desidero più ricevere i messaggi di un gruppo. + + +

+ +
    +
  • +

    Elimina te stesso dall’elenco dei membri o elimina l’intera chat. +Se vuoi unirti di nuovo al gruppo in un secondo momento, chiedi a un altro membro del gruppo di aggiungerti di nuovo.

    +
  • +
  • +

    In alternativa, puoi anche “Silenziare” un gruppo - così facendo riceverai tutti i messaggi e +puoi ancora scrivere, ma non viene più notificato alcun nuovo messaggio.

    +
  • +
+ +

+ + + Clonazione di un gruppo + + +

+ +

Puoi duplicare un gruppo per avviare una discussione separata +o per escludere membri senza che se ne accorgano.

+ +
    +
  • +

    Apri il profilo del gruppo e tocca Clona Chat (Android/iOS), +oppure fai clic con il pulsante destro del mouse sul gruppo nell’elenco delle chat (Desktop).

    +
  • +
  • +

    Imposta un nuovo nome, scegli un avatar e modifica l’elenco dei membri, se necessario.

    +
  • +
+ +

Il nuovo gruppo è completamente indipendente dall’originale, +che continua a funzionare come prima.

+ +

+ + + Apps in chat + + +

+ +

È possibile inviare apps a una chat: giochi, editor, sondaggi e altri strumenti. +Questo rende Delta Chat un servizio di messaggistica davvero espandibile.

+ +

+ + + Dove posso trovare le apps in chat? + + +

+ +
    +
  • +

    In una chat, utilizzando Paperclip Pulsante Allegato → Apps

    +
  • +
  • +

    Puoi anche creare la tua app e allegarla usando Paperclip Pulsante Allegato → File

    +
  • +
+ +

+ + + Quanto sono private le apps di chat? + + +

+ +
    +
  • +

    Le apps di chat non possono inviare dati a Internet né scaricare nulla.

    +
  • +
  • +

    Un’app in-chat può scambiare dati solo all’interno di una chat Delta Chat, con le sue +copie sui dispositivi dei tuoi interlocutori. A parte questo, è completamente +isolata da Internet.

    +
  • +
  • +

    La privacy offerta da un’app di chat è la privacy della tua chat: finché +ti fidi delle persone con cui chatti, puoi fidarti anche dell’app di chat.

    +
  • +
  • +

    questo significa anche: proprio come per i link web, non aprire app provenienti da contatti non attendibili.

    +
  • +
+ +

+ + + Come posso creare le mie app di chat? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Consegna messaggi istantanei e Notifiche Push + + +

+ +

+ + + Cosa sono le Notifiche Push? Come posso ottenere la consegna dei messaggi istantanei? + + +

+ +

Le Notifiche Push vengono inviate dai “Servizi push” di Apple e Google al dispositivo di un utente +in modo che un’app Delta Chat inattiva possa recuperare i messaggi in background +e mostrare le notifiche sul telefono di un utente, se necessario.

+ +

Le Notifiche Push funzionano con tutti i server chatmail attivi

+ +
    +
  • +

    Dispositivi iOS, attraverso l’integrazione con i servizi Push di Apple.

    +
  • +
  • +

    Dispositivi Android, attraverso l’integrazione con il servizio Google FCM Push, +anche sui dispositivi che utilizzano microG +invece del codice proprietario di Google sul telefono.

    +
  • +
+ +

+ + + Le Notifiche Push sono abilitate sui dispositivi iOS? Esiste un’alternativa? + + +

+ +

Sì, Delta Chat utilizza automaticamente le notifiche push per i profili chatmail. +E no, non esiste alcuna alternativa sui telefoni Apple per ottenere la consegna dei messaggi istantanei +perché i dispositivi Apple non consentono a Delta Chat di recuperare i dati in background. +Le notifiche push vengono attivate automaticamente per gli utenti iOS perché +Sistema di notifiche push che tutela la privacy di Delta Chat +non espone ad Apple i dati che non possiede già.

+ +

+ + + Le Notifiche Push sono abilitate / necessarie sui dispositivi Android? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

Nelle impostazioni “Notifiche” di Delta Chat per “Consegna istantanea” +puoi modificare le seguenti impostazioni che interessano tutti i profili chat:

+ +
    +
  • +

    Utilizza Connessione in Background: se non stai utilizzando un servizio Push, +puoi disattivare le “ottimizzazioni della batteria” per Delta Chat, +permettendogli di recuperare i messaggi in background. +Tuttavia potrebbero verificarsi ritardi da minuti ad ore. +Alcuni fornitori Android limitano addirittura completamente le app +(vedi dontkillmyapp.com) +e Delta Chat potrebbe non mostrare i messaggi in arrivo +finché non riapri manualmente l’app.

    +
  • +
  • +

    Forza Connessione Background: questa è l’opzione di ripiego +se le opzioni precedenti non sono disponibili o non raggiungono la “consegna immediata”. +Abilitandolo si genera una notifica permanente sul tuo telefono +che a volte può essere “minimizzato” con i recenti telefoni Android.

    +
  • +
+ +

Entrambe le opzioni “Connessione in Background” sono efficienti dal punto di vista energetico ed +è sicuro provarlo se riscontri che i messaggi arrivano solo con lunghi ritardi.

+ +

+ + + Quanto sono private le Notifiche Push di Delta Chat? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Ecco come le app Delta Chat eseguono l’invio delle Notifiche Push:

+ +
    +
  • +

    Un’app Delta Chat ottiene un “token del dispositivo” localmente, lo crittografa e lo memorizza sul server chatmail.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

Il proxy di notifica centrale di Delta Chat è piccolo e completamente implementato in Rust +e si dimentica dei gettoni del dispositivo non appena Apple/Google/ecc li elabora, +di solito nel giro di pochi millisecondi.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Come risultato di questo disegno complessivo sulla riservatezza, anche il sequestro di un server chatmail, +o il sequestro totale del proxy di notifica centrale di Delta Chat +non rivelerebbe informazioni private che i servizi Push non abbiano già.

+ +

+ + + Perché Delta Chat si integra con i servizi push centralizzati proprietari di Apple/Google? + + +

+ +

Delta Chat è un messenger decentralizzato gratuito e open source con scelta libera del server, +ma vogliamo che gli utenti sperimentino in modo affidabile la “consegna istantanea” dei messaggi, +come sperimentano dalle app Whatsapp, Signal o Telegram, +senza porre domande in anticipo, più adatte a utenti esperti o sviluppatori.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-client + + +

+ +

+ + + Posso utilizzare Delta Chat su più dispositivi contemporaneamente? + + +

+ +

Sì. Puoi usare lo stesso profilo su diversi dispositivi:

+ +
    +
  • +

    Assicurati che entrambi i dispositivi siano collegati alla stessa rete Wi-Fi o network

    +
  • +
  • +

    Sul primo dispositivo, andare su Impostazioni → Aggiungi Secondo Dispositivo, sbloccare lo schermo se necessario +e attendere un attimo fino a quando non viene visualizzato un codice QR

    +
  • +
  • +

    Sul secondo dispositivo, installare Delta Chat

    +
  • +
  • +

    Sul secondo dispositivo, avviare Delta Chat, selezionare Aggiungi Come Secondo Dispositivo e scansionare il codice QR del vecchio dispositivo.

    +
  • +
  • +

    Il trasferimento dovrebbe iniziare dopo pochi secondi e durante il trasferimento entrambi i dispositivi mostreranno il progresso. + Attendere il termine del trasferimento su entrambi i dispositivi.

    +
  • +
+ +

A differenza di molti altri messengers, dopo un trasferimento riuscito, +entrambi i dispositivi sono completamente indipendenti. +Un dispositivo non è necessario perché l’altro funzioni.

+ +

+ + + Risoluzione dei problemi + + +

+ +
    +
  • +

    Verificare che entrambi i dispositivi siano nella stessa rete o Wi-Fi.

    +
  • +
  • +

    Su Windows, vai su Pannello di controllo / Rete e Internet +e assicurati che Rete Privata sia selezionata come “Tipo di profilo di rete” +(dopo il trasferimento è possibile ripristinare il valore originale)

    +
  • +
  • +

    Su iOS, assicurati che l’accesso a “Impostazioni di Sistema / App / Delta Chat / Rete locale” sia concesso

    +
  • +
  • +

    Su macOS, abilita “Impostazioni di Sistema / Privacy & Sicurezza / Rete locale / Delta Chat”

    +
  • +
  • +

    Il sistema potrebbe avere un “personal firewall”, +che è noto per causare problemi (soprattutto su Windows). +Disattivare il firewall personale per Delta Chat su entrambe le estremità e riprovare.

    +
  • +
  • +

    Reti Ospite potrebbero non consentire ai dispositivi di comunicare tra loro. +Se possibile, utilizza una rete non ospite.

    +
  • +
  • +

    Se continui ad aver problemi usando la stessa rete, +prova ad aprire l’ Hotspot su uno dei dispositivi e collegati al quel Wi-Fi con l’altro

    +
  • +
  • +

    Assicurarsi che sul dispositivo di destinazione ci sia disponibilità di memoria sufficiente.

    +
  • +
  • +

    Se il trasferimento è iniziato, assicurarsi che i dispositivi rimangano attivi e non si addormentino. +Non uscire da Delta Chat. +(cerchiamo di far funzionare l’applicazione in background, ma i sistemi tendono a uccidere le applicazioni, purtroppo).

    +
  • +
  • +

    Delta Chat è già connesso sul dispositivo di destinazione? +È possibile utilizzare più profili per dispositivo, basta aggiungere un altro profilo

    +
  • +
  • +

    Se si riscontrano ancora problemi o se non si riesce a scansionare un codice QR +provate il trasferimento manuale descritto di seguito

    +
  • +
+ +

+ + + Trasferimento manuale + + +

+ +

Questo metodo è consigliato solo se “Aggiungi Secondo Dispositivo” come descritto sopra non funziona.

+ +
    +
  • Sul vecchio dispositivo, vai su “Impostazioni -> Chat e Media -> Backup Chat su Memoria Esterna”. Inserisci il tuo +PIN, sequenza o password di sblocco dello schermo. Quindi puoi fare clic su “Avvia +Backup”. Questo salva il file di backup sul tuo dispositivo. Ora devi trasferirlo +in qualche modo all’altro dispositivo.
  • +
  • Sul nuovo dispositivo, nella schermata di accesso, invece di accedere al tuo profilo +e-mail, seleziona “Importa backup”. Dopo l’importazione, le tue conversazioni, la crittografia +i tasti e i supporti devono essere copiati sul nuovo dispositivo.
  • +
  • Se usi iOS: e incontri difficoltà, forse +questa guida +Aiutarti.
  • +
  • Ora sei sincronizzato e puoi utilizzare entrambi i dispositivi per inviare e ricevere +messaggi crittografati end-to-end con i tuoi partner di comunicazione.
  • +
+ +

+ + + Sono previsti piani per l’introduzione di un client Web Delta Chat? + + +

+ +
    +
  • Non ci sono piani immediati ma alcune riflessioni preliminari.
  • +
  • Ci sono 2-3 strade per introdurre un client Web Delta Chat, ma tutte sono +lavoro significativo. Per ora, ci concentriamo sull’ottenere versioni stabili in tutti +gli app store (repository Google Play/iOS/Windows/macOS/Linux) come app native.
  • +
  • Se hai bisogno di un Client Web, perché non sei autorizzato a installare software sul +computer con cui lavori, puoi utilizzare il client Desktop Windows Portatile, +o l’AppImage per Linux. Le trovi su +get.delta.chat.
  • +
+ +

+ + + Avanzato + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + Vorrei gestire il mio server per Delta Chat. Cosa mi consigliate? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Sono interessato ai dettagli tecnici. Mi puoi dire di più? + + +

+ + + +

+ + + Crittografia e Sicurezza + + +

+ +

+ + + Quali standards vengono utilizzati per la crittografia end-to-end? + + +

+ +

Delta Chat utilizza un sottoinsieme sicuro dello standard OpenPGP +per fornire la crittografia end-to-end automatica utilizzando questi protocolli:

+ +
    +
  • +

    Secure-Join +per scambiare informazioni sulla configurazione della crittografia tramite la scansione del codice QR o i “link di invito”.

    +
  • +
  • +

    Autocrypt viene utilizzato per stabilire +automaticamente la crittografia end-to-end tra i contatti e tutti i membri di una chat di gruppo.

    +
  • +
  • +

    Condivisione di un contatto con una +chat +consente ai destinatari di utilizzare la crittografia end-to-end con il contatto.

    +
  • +
+ +

Delta Chat non esegue query, pubblica o interagisce con alcun server di chiavi OpenPGP.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

Tutti i messaggi in Delta Chat sono crittografati end-to-end per impostazione predefinita. +Dalla serie di rilasci della Versione 2 di Delta Chat (Luglio 2025) +non ci sono più lucchetti o marcatori simili, sui messaggi crittografati end-to-end.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

Se si utilizzano i relays di chatmail, +è impossibile ricevere o inviare messaggi senza la crittografia end-to-end.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + Cosa significa il segno di spunta verde nel profilo di un contatto? + + +

+ +

Il profilo di un contatto potrebbe mostrare una spunta verde +green checkmark +e una linea “Verificato da”. +Ogni contatto con la spunta verde ha fatto un QR-scan con te +o è stato verificato da un altro contatto con la spunta verde. +La verifica si ha automaticamente all’aggiunta dei membri nei gruppi. +Chiunque aggiunga un contatto con spunta verde a un gruppo con solo membri con la spunta verde +diventa colui che l’ha verificato. +Nel profilo di un contatto puoi premere sul campo “Verificato da …” ripetutamente +fino a che vedi con chi hai avuto un QR-scan.

+ +

Per una discussione più approfondita sulla “crittografia end-to-end garantita” +consultare Protocolli Secure-Join +e leggi nello specifico i “Gruppi Verificati”, il termine tecnico +di quelle che qui vengono chiamate chat “con segno di spunta verde” o “crittografate end-to-end garantite”.

+ +

+ + + Gli allegati (immagini, file, audio, ecc.) sono crittografati end-to-end? + + +

+ +

Sì.

+ +

Quando parliamo di “messaggio crittografato end-to-end” +intendiamo sempre che l’intero messaggio è crittografato, +compresi tutti gli allegati +e metadati degli allegati come i nomi dei file.

+ +

+ + + OpenPGP è sicuro? + + +

+ +

Sì, Delta Chat utilizza un sottoinsieme sicuro di OpenPGP +che richiede che l’intero messaggio sia correttamente crittografato e firmato. +Ad esempio, le “Firme staccate” non sono considerate sicure.

+ +

OpenPGP non è insicuro di per sé. +La maggior parte dei problemi di sicurezza di OpenPGP discussi pubblicamentederivano in realtà da una cattiva usabilità o da cattive implementazioni di strumenti o app (o entrambi). +È particolarmente importante distinguere tra OpenPGP, lo standard di crittografia IETF, +e GnuPG (GPG), uno strumento da riga di comando che implementa OpenPGP. +Molte critiche pubbliche di OpenPGP in realtà discutono di GnuPG che Delta Chat non ha mai utilizzato. +Delta Chat utilizza piuttosto l’implementazione Rust di OpenPGP rPGP, +disponibile come un pacchetto “pgp” indipendente, +e verificato per la sicurezza nel 2019 e nel 2024.

+ +

Puntiamo, insieme ad altri implementatori di OpenPGP, +per migliorare ulteriormente le caratteristiche di sicurezza implementando il +nuovo aggiornamento crittografico IETF OpenPGP che per fortuna è stato adottato nell’estate 2023.

+ +

+ + + Avete considerato l’utilizzo di alternative a OpenPGP per la crittografia end-to-end? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat adotta un approccio olistico di “sicurezza utilizzabile”. +e lavora anche con una vasta gamma di gruppi di attivisti +ricercatori rinomati come TeamUSEC +per migliorare i risultati effettivi degli utenti contro le minacce alla sicurezza. +Il protocollo dei messaggi e lo standard per stabilire la crittografia end-to-end è +solo una parte dei “risultati utente”, +vedi anche le nostre risposte a device-seizure +e domande su metadati-messaggio.

+ +

+ + + Delta Chat è vulnerabile agli EFAIL? + + +

+ +

No, Delta Chat non è mai stato vulnerabile all’EFAIL +perché la sua implementazione OpenPGP rPGP +utilizza il codice di rilevamento delle modifiche durante la crittografia dei messaggi +e restituisce un errore +se il codice di rilevamento della modifica non è corretto.

+ +

Inoltre, Delta Chat non è mai stata vulnerabile all’attacco EFAIL “Direct Exfiltration” +perché decodifica solo i messaggi “multipart/encrypted”. +che contengono esattamente una parte crittografata e firmata, +come definito dalla specifica Autocrypt Level 1.

+ +

+ + + I messaggi contrassegnati dall’icona della posta sono esposti su Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + In che modo Delta Chat protegge i metadati nei messaggi? + + +

+ +

A differenza della maggior parte degli altri servizi di messaggistica, +le apps Delta Chat non memorizzano alcun metadato sui contatti o sui gruppi sui server, né in forma crittografata. +Tutti i metadati dei gruppi sono invece crittografati end-to-end e memorizzati esclusivamente sui dispositivi degli utenti finali.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

Tutti gli altri metadati dei messaggi, dei contatti e dei gruppi risiedono nella parte crittografata end-to-end dei messaggi.

+ +

+ + + Come proteggere i metadati e contatti quando un dispositivo viene sequestrato? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Delta Chat supporta “Mittente Sigillato”? + + +

+ +

No, non ancora.

+ +

Il messenger Signal ha introdotto “Mittente Sigillato” nel 2018 +per impedire che la propria infrastruttura server venga a conoscenza di chi sta inviando un messaggio a un insieme di destinatari. +È particolarmente importante perché il server Signal conosce il numero di cellulare di ciascun profilo, +che di solito è associato a un’identità tramite passaporto.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Delta Chat supporta Perfect Forward Secrecy? + + +

+ +

No, non ancora.

+ +

Delta Chat al momento non supporta la tecnologia Perfect Forward Secrecy (PFS). +Ciò significa che se la tua chiave di decrittazione privata viene divulgata +e qualcuno ha raccolto i tuoi messaggi in transito precedenti, +sarà in grado di decifrarli e leggerli utilizzando la chiave di decrittazione divulgata. +Tieni presente che la tecnologia Forward Secrecy aumenta la sicurezza solo se elimini i messaggi. +In caso contrario, chi ottiene le tue chiavi di decrittazione +in genere è in grado di ottenere anche tutti i tuoi messaggi non eliminati +e non ha nemmeno bisogno di decifrare i messaggi raccolti in precedenza.

+ +

Abbiamo progettato un approccio Forward Secrecy che ha superato +l’esame iniziale di alcuni crittografi ed esperti di implementazione +ma è in attesa di una stesura più formale +per accertarne l’affidabilità nella messaggistica federata e nell’utilizzo su più dispositivi, +prima di poter essere implementato in chatmail core, +che lo renderebbe disponibile in tutti i clients di chatmail.

+ +

+ + + Delta Chat supporta la Crittografia Post-Quantistica? + + +

+ +

No, non ancora.

+ +

Delta Chat utilizza la libreria Rust OpenPGP rPGP +che supporta l’ultima bozza IETF Post-Quantum-Cryptography OpenPGP. +Il nostro obiettivo è aggiungere il supporto PQC nel core di chatmail dopo che la bozza sarà stata finalizzata dall’IETF +in collaborazione con altri implementatori di OpenPGP.

+ +

+ + + Come posso controllare manualmente le informazioni di crittografia? + + +

+ +

È possibile verificare manualmente lo stato della crittografia end-to-end nella finestra di dialogo “Crittografia”. +(profilo utente su Android/iOS o clic con il pulsante destro del mouse sull’elemento dell’elenco chat di un utente sul desktop). +Delta Chat mostra due impronte digitali. +Se sul tuo dispositivo e su quello del tuo contatto vengono visualizzate le stesse impronte digitali, +la connessione è sicura.

+ +

+ + + Posso riutilizzare la mia chiave privata esistente? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Delta Chat è stata verificata in modo indipendente per le vulnerabilità di sicurezza? + + +

+ +

Sì, più volte. +Il progetto Delta Chat è sottoposto costantemente a verifiche e analisi di sicurezza indipendenti, +dal più recente al più vecchio:

+ +
    +
  • +

    Dicembre 2024, un Valutazione commissionata da NLNET di +rPGP di Radically Open Security ha preso parte. +rPGP serves as the end-to-end encyption OpenPGP engine of Delta Chat. +Sono stati rilasciati due avvisi relativi ai risultati di questa verifica:

    + + + +

    I problemi descritti in questi avvisi sono stati risolti e fanno +parte delle versioni di Delta Chat +su tutti gli appstore da Dicembre 2024.

    +
  • +
  • +

    Marzo 2024, abbiamo ricevuto un’analisi approfondita della sicurezza dalla Crittografia Applicata +gruppo di ricerca dell’ETH di Zurigo e ha affrontato tutte le questioni sollevate. +Consulta il nostro post sul blog su Rafforzamento della crittografia end-to-end garantita per informazioni più dettagliate e +Analisi crittografica di Delta Chat +articolo di ricerca pubblicato successivamente.

    +
  • +
  • +

    A partire dal 2023, abbiamo risolto i problemi di sicurezza e privacy con il servizio “web +app condivise in una chat”, relativa ai guasti del sandboxing +soprattutto con Chromium. Successivamente abbiamo ottenuto una sicurezza indipendente +audit da Cure53 e tutti i problemi rilevati sono stati risolti nella serie di app 1.36 rilasciata nell’aprile 2023. +Vedi qui per la storia completa sulla sicurezza end-to-end nel web.

    +
  • +
  • +

    A partire dal 2023, Cure53 ha analizzato sia la crittografia del trasporto delle +Connessioni di rete di Delta Chat e una configurazione del server di posta riproducibile come +consigliato su questo sito. +Puoi leggere ulteriori informazioni sull’audit sul nostro blog +o leggere il rapporto completo qui.

    +
  • +
  • +

    Nel 2020, Include Security ha analizzato il Delta +Chat’s Rust core, +IMAP, +SMTP e +TLS librerie. +Non ha rilevato problemi critici o di elevata gravità. +Il rapporto ha sollevato alcuni punti deboli di media gravità: +da soli non rappresentano una minaccia per gli utenti di Delta Chat +perché dipendono dall’ambiente in cui viene utilizzato Delta Chat. +Per motivi di usabilità e compatibilità, +non possiamo mitigarli tutti +e ha deciso di fornire consigli sulla sicurezza agli utenti minacciati. +Puoi leggere il rapporto completo qui.

    +
  • +
  • +

    Nel 2019, Include Security ha analizzato le librerie +PGP e +RSA di Delta Chat. +Non ha riscontrato criticità, +ma due problemi di elevata gravità che abbiamo successivamente risolto. +Ne sono emersi anche uno di media gravità e alcuni problemi meno gravi, +ma non c’era modo di sfruttare queste vulnerabilità nell’implementazione di Delta Chat. +Alcuni di essi sono stati comunque corretti dopo la conclusione dell’audit. +Puoi leggere il rapporto completo qui.

    +
  • +
+ +

+ + + Varie + + +

+ +

+ + + Di quali autorizzazioni ha bisogno Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Dove possono trovare Delta Chat i miei amici? + + +

+ +

Delta Chat è disponibile per tutte le piattaforme principali e alcune minori:

+ + + +

+ + + Come viene finanziato lo sviluppo di Delta Chat? + + +

+ +

Delta Chat non riceve alcun capitale di rischio e +non è indebitato e non è sotto pressione per produrre enormi profitti, o per farlo +vendere utenti e i loro amici e familiari agli inserzionisti (o peggio). +Utilizziamo piuttosto fonti di finanziamento pubblico, così lontane dalle origini dell’UE e degli Stati Uniti, per aiutare +i nostri sforzi nell’istigare un ecosistema di messaggistica di chat decentralizzato e diversificato +basato sugli sviluppi della comunità Free e Open-Source.

+ +

Concretamente, lo sviluppo di Delta Chat è stato finora finanziato da queste fonti, +ordinate cronologicamente:

+ +
    +
  • +

    Il progetto UE NEXTLEAP ha finanziato la ricerca +e implementazione di gruppi verificati e impostazione di protocolli di contatto +nel 2017 e nel 2018 e ha anche contribuito a integrare la crittografia end-to-end +tramite Autocrypt.

    +
  • +
  • +

    L’Open Technology Fund ci ha dato una +prima sovvenzione 2018/2019 (~$200K) durante la quale abbiamo notevolmente migliorato l’app Android +e ha rilasciato una prima versione beta dell’app desktop, e che inoltre +ancorato i nostri sviluppi delle funzionalità nella ricerca sulla UX nei contesti dei diritti umani, +vedete il nostro Rapporto Needfinding e UX conclusivo. +La seconda sovvenzione 2019/2020 (~$300K) ci ha aiutato a farlo +rilasciare nelle versioni Delta/iOS, per convertire la nostra libreria principale in Rust, e +per fornire nuove funzionalità per tutte le piattaforme.

    +
  • +
  • +

    La fondazione NLnet ha concesso nel 2019/2020 46.000 EUR per +completando i collegamenti Rust/Python e avviando un ecosistema Chat-bot.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 abbiamo completato con successo il progetto Chatmail Sicuro finanziato da OTF, +consentendoci di introdurre la crittografia garantita, +creando una rete di server di chatmail +e fornendo “inserimento immediato” in tutte le app rilasciate da aprile 2024 in poi.

    +
  • +
  • +

    Nel 2023 e nel 2024 siamo stati accettati nel programma Next Generation Internet (NGI) +per il nostro lavoro in webxdc PUSH, +insieme ai partner di collaborazione che lavorano su +webxdc evolve, +webxdc XMPP, +DeltaTouch e +DeltaTauri. +Tutti questi progetti sono parzialmente completati o saranno completati all’inizio del 2025.

    +
  • +
  • +

    A volte riceviamo donazioni una tantum da privati. +Ad esempio, nel 2021 un generoso individuo ci ha trasferito in banca 4K EUR +con l’oggetto “continuate il buon sviluppo!”. 💜 +Usiamo questi soldi per finanziare incontri di sviluppo o per sostenere spese ad hoc +che non possono essere facilmente previsti o rimborsati da finanziamenti pubblici. +Ricevere più donazioni ci aiuta anche a diventare più indipendenti e vitali a lungo termine +come comunità di contributori.

    + + +
  • +
  • +

    Ultimo ma non meno importante, hanno contribuito diversi esperti e appassionati pro-bono +e contribuito allo sviluppo di Delta Chat senza ricevere denaro, o solo +piccole quantità. Senza di loro, Delta Chat non sarebbe dove è oggi, nemmeno +vicino.

    +
  • +
+ +

Il finanziamento monetario di cui sopra è per lo più organizzato da merlinux GmbH in +Friburgo (Germania) ed è distribuito a più di una dozzina di contributori in tutto il mondo.

+ +

Consulta Canali contribuzione di Delta Chat +sia per le possibilità monetarie che contributive.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/mic.png b/src/main/assets/help/mic.png new file mode 100644 index 0000000000000000000000000000000000000000..ed5d44845a84e008da9a5e54c2f4bb335791e132 Binary files /dev/null and b/src/main/assets/help/mic.png differ diff --git a/src/main/assets/help/nl/help.html b/src/main/assets/help/nl/help.html new file mode 100644 index 0000000000000000000000000000000000000000..969f7da2a53671c64d33dc9a01c4aa602310c902 --- /dev/null +++ b/src/main/assets/help/nl/help.html @@ -0,0 +1,1665 @@ + + + + + +

+ + + Wat is Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    Accepteer het verzoek om te kunnen antwoorden.

    +
  • +
  • +

    Ook kun je het verzoek verwijderen als je op dat moment geen gesprek met ze wilt voeren.

    +
  • +
  • +

    Als je een verzoek verwijderd, dan worden toekomstige berichten nog steeds als verzoek getoond, +zodat je te allen tijde kunt bepalen of je het alsnog wilt accepteren. Als je écht geen contact wilt leggen, overweeg dan +om de persoon in kwestie te blokkeren.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Ondersteunt Delta Chat afbeeldingen, video’s en ander soort bijlagen? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    Om de prestaties te verhogen, worden afbeeldingen standaard geoptimaliseerd en verkleind verstuurd, maar je kunt ze als een bestand verzenden om het origineel te sturen.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Wie kan mijn profielfoto zien? + + +

+ +
    +
  • +

    In de instellingen kun je een profielfoto toevoegen. Als je een bericht stuurt aan +je contactpersonen of ze toevoegt middels hun QR-code, dan krijgen ze je profielfoto te zien.

    +
  • +
  • +

    Omwille van je privacy, krijgen anderen je profielfoto pas te zien +als je ze een bericht stuurt.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + Wat is vastmaken, negeren en archiveren? + + +

+ +

Met deze hulpmiddelen kun je je gesprekken geordend houden:

+ +
    +
  • +

    Vastgemaakte gesprekken vind je te allen tijde bovenaan de gesprekslijst terug. Zo kun je je belangrijste gesprekken permanent of tijdelijk bij de hand houden, zodat je niets vergeet.

    +
  • +
  • +

    Stel gesprekken in op Negeren als je geen meldingen meer wilt ontvangen. Wel blijven genegeerde gesprekken op de lijst staan en kun je ze te allen tijde vastmaken.

    +
  • +
  • +

    Archiveer gesprekken als je ze niet meer op de gesprekslijst wilt zien. +Gearchiveerde gesprekken zijn te allen tijde te bekijken boven de lijst of via een zoekopdracht.

    +
  • +
  • +

    Als er een nieuw bericht in een gearchiveerd gesprek wordt ontvangen, dan wordt het gesprek in kwestie ge-dearchiveerd en dus weer op de gesprekslijst geplaatst. +Genegeerde gesprekken blijven in het archief staan totdat je ze er zelf uithaalt.

    +
  • +
+ +

Archiveer of maak een gesprek vast door het gesprek in kwestie lang ingedrukt te houden (Android), via het gespreksmenu (Android/computer) of door het naar links te vegen (iOS). +Negeer een gesprek via het gespreksmenu (Android/computer) of het gespreksprofiel (iOS).

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + Wat betekent die groene stip? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Wat betekenen de vinkjes naast verzonden berichten? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + Wat gebeurt er als ik ‘Oude berichten van server verwijderen’ inschakel? + + +

+ +
    +
  • Als je ruimte wilt besparen op je apparaat, dan kun je er voor kiezen om oude +berichten automatisch te verwijderen.
  • +
  • Inschakelen kan via de sectie ‘Gesprekken en media’ in de instellingen. Je kunt een periode tussen +‘na één uur’ en ‘na één jaar’ kiezen. *Alle berichten die ouder zijn, worden verwijderd.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Groepen aanmaken + + +

+ +
    +
  • Open het ‘menu met de drie puntjes’ rechtsboven in het gespreksoverzicht, kies Nieuw gesprek en daarna Nieuwe groep.
  • +
  • Kies dan de groepsleden en druk op het vinkje rechtsboven. Daarna kun je een groepsnaam opgeven.
  • +
  • Zodra je het eerste groepsbericht hebt verstuurd, worden alle deelnemers op de hoogte gebracht en kunnen zij antwoorden versturen (de groep blijft onzichtbaar voor anderen zolang jij geen bericht verstuurt).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Ik heb mezelf per ongeluk verwijderd + + +

+ +
    +
  • Je neemt geen deel meer aan de groep en kunt jezelf dus niet meer toevoegen. +Vraag iemand via een één-op-ééngesprek of hij/zij je weer wilt toevoegen.
  • +
+ +

+ + + Ik wil geen groepsberichten meer ontvangen + + +

+ +
    +
  • +

    Verwijder jezelf van de groepslijst of verwijder het hele groepsgesprek. +Als je later weer wilt deelnemen, vraag dan iemand anders of hij/zij je weer wilt toevoegen.

    +
  • +
  • +

    Wat ook kan doen is groepsmeldingen uitschakelen. Zo blijf je in de groep, maar ontvang je +geen meldingen meer als er nieuwe berichten zijn.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-client + + +

+ +

+ + + Kan ik Delta Chat op meerdere apparaten tegelijk gebruiken? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Controleer of beide apparaten verbonden zijn met hetzelfde (wifi)netwerk

    +
  • +
  • +

    Ga op het eerste apparaat naar Instellingen → Tweede apparaat toevoegen, ontgrendel - indien nodig - het scherm +en wacht totdat de QR-code in beeld verschijnt.

    +
  • +
  • +

    Installeer Delta Chat op het tweede apparaat.

    +
  • +
  • +

    Start Delta Chat op het tweede apparaat, kies Toevoegen als tweede apparaat en scan de QR-code met het eerste apparaat.

    +
  • +
  • +

    De overdracht zou na een paar seconden moeten beginnen en beide apparaten zouden de voortgang moeten tonen. +Wacht vervolgens tot de overdracht op beide apparaten is afgerond.

    +
  • +
+ +

In tegenstelling tot veel andere gespreksapps, werkt Delta Chat onafhankelijk +op beide apparaten. Hierdoor hoef je niet het ene apparaat bij de hand te hebben om het andere te laten werken.

+ +

+ + + Probleemoplossing + + +

+ +
    +
  • +

    Controleer of beide apparaten verbonden zijn met hetzelfde (wifi)netwerk

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Wellicht is een firewall actief op je apparaat, +welke problemen kan veroorzaken (met name op Windows). +Schakel de firewall op beide apparaten uit of voeg een uitzondering voor Delta Chat toe en probeer het opnieuw.

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Zorg voor voldoende ruimte op het bestemmingsapparaat

    +
  • +
  • +

    Zorg dat beide apparaten tijdens de overdracht ingeschakeld blijven en niet op zwart/in de slaapstand gaan. +Sluit Delta Chat niet af. +(We proberen om de app zo goed als mogelijk op de achtergrond te laten werken, maar systemen sluiten apps helaas wel eens eigenhandig af).

    +
  • +
  • +

    Ben je al ingelogd op het bestemmingsapparaat? +Je kunt meerdere accounts per apparaat gebruiken - voeg dus een tweede account toe.

    +
  • +
  • +

    Als je nog steeds problemen ervaart of je de QR-code niet kunt scannen, +volg dan onderstaande stappen omtrent handmatige overzetting

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

Deze methode is vooral bedoeld voor situaties waarin ‘Tweede apparaat toevoegen’ niet lukt.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Bestaan er plannen om een Delta Chat-webclient te maken? + + +

+ +
    +
  • Er zijn nog geen concrete plannen; alleen wat gedachtenspelingen.
  • +
  • Er zijn 2 à 3 obstakels, maar alle vereisen zware +inspanningen. Momenteel ligt voor ons de focus op stabiele uitgaven maken voor appwinkels +(Google Play/iOS/Windows/macOS/Linux-pakketbronnen).
  • +
  • Als je een webclient nodig hebt omdat je geen software mag installeren op +je computer, dan kun je de meeneembare (portable) versie gebruiken van de Windows-client, +of de AppImage van de Linux-client. Deze kun je downloaden op + get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Ik wil graag meer weten over de gebruikte technieken. Waar kan ik meer informatie vinden? + + +

+ + + +

+ + + Beveiliging en versleuteling + + +

+ +

+ + + Welke standaarden worden gebruikt bij eind-tot-eindversleuteling? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Yes.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Kan ik mijn bestaande privésleutel hergebruiken? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Heeft Delta Chat ooit onafhankelijke beveiligingscontroles ondergaan? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    Sinds begin 2023 hebben we diverse beveiligings- en privacyproblemen met ‘webapps +gedeeld in een gesprek’. Deze waren allen te wijten aan fouten in de sandboxing, +vooral die van Chromium. Daarna is er een beveiligings- +onderzoek van Cure53 geweest en zijn alle problemen opgelost in versie 1.36 van de uit april 2023. +Lees hier het volledige verhaal omtrent E2E-beveiliging op het web.

    +
  • +
  • +

    Aan het begin van 2023 heeft Cure53 de transportversleuteling van +Delta Chats netwerkverbindingen getest, evenals de e-mailserveropzet zoals +beschreven op onze site. +Meer informatie over deze test is te lezen op ons blog +of in het volledige verslag.

    +
  • +
  • +

    In 2020 heeft Include Security Delta Chats +Rust-kern, +imap-, +smtp- en +tls-bibliotheken geanalyseerd. +Er werden geen grote problemen aangetroffen. +Wél werden er een paar redelijk belangrijke zwakheden aangetroffen, +maar geen die de meeste Delta Chat-gebruikers direct trof +omdat ze afhankelijk waren van de gebruikte omgeving. +Omwille van gebruiks- en compatibiliteitsredenen, +konden we ze niet allemaal oplossen +en besloten we om beveiligingsaanbevelingen aan getroffen gebruikers te doen. +Het volledige verslag is hier na te lezen.

    +
  • +
  • +

    In 2019 heeft Include Security Delta Chats +PGP- en +RSA-bibliotheken geanalyseerd. +Er werden geen grote problemen aangetroffen, +maar wel twee belangrijke die nadien werden opgelost. +Ook werden enkele redelijk belangrijke en minder belangrijke aan het licht gebracht, +maar die konden in Delta Chats specifieke implementatie niet worden misbruikt. +Ondanks dat zijn enkele daarvan nadien alsnog opgelost. +Het volledige verslag is hier na te lezen.

    +
  • +
+ +

+ + + Overig + + +

+ +

+ + + Welke Android-rechten heeft Delta Chat nodig? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Hoe wordt de ontwikkeling van Delta Chat gefinancierd? + + +

+ +

Delta Chat ontvangt geen risicokapitalen, +staat niet onder bewindvoering en ervaart geen enkele druk om winst te maken of om +gebruikers en hun vrienden door te verkopen aan adverteerders (of erger). +We maken gebruik van publieke financieringsprocessen, zoals EU- en VS-financiering, om ons doel, +het opzetten van een gedecentraliseerd en divers gesprekssysteem, te verwezenlijken, +op basis van vrije en opensource-gemeenschapsontwikkelingen.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    Open Technology Fund heeft twee subsidies toegekend. +De eerste subsidie, voor 2018/2019, ter waarde van ong. $200,000, heeft enorm geholpen om de Android-app +te verbeteren en een bètaversie van de computerclient vrij te geven. +Verder hebben we onderzoek kunnen doen naar het uiterlijk in relatie tot mensenrechten - +bekijk onze conclusie hier: Needfinding and UX report. +De tweede subsidie, voor 2019/2020, ter waarde van ong. $300,000, loopt nog en ondersteunt ons bij het +vrijgeven van de iOS-client, het overzetten van de code van de kernbibliotheek naar Rust en +het implementeren van nieuwe functies op alle platformen.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Soms ontvangen we eenmalige donaties van privépersonen, waar we +uiteraard zeer dankbaar voor zijn. Zo ontvingen we in 2021 een zeer royaal bedrag op onze rekening, te weten €4000, +met als bijschrift “Ga zo door met dit goede project!”. We gebruiken dit soort bedragen om +ontmoetingen tussen ontwikkelaars te organiseren of voor ad-hoc-uitgaven die niet voorzien waren. +Ook kunnen we zo onafhankelijk blijven en lang blijven +voortbestaan.

    + + +
  • +
  • +

    Ook hebben verschillende experts en enthousiastelingen op vrijwillige basis bijgedragen +aan Delta Chat, en sommige doen dat nog steeds. Zij hebben geen of bijna geen +geld ontvangen. Zonder hen zou Delta Chat niet zo geweldig zijn als het vandaag +de dag is.

    +
  • +
+ +

Bovenstaande fiancieringen zijn opgezet door merlinux GmbH in +Freiburg (Duitsland) en daarna toegekend aan meer dan 12 vrijwilligers wereldwijd.

+ +

Bekijk Delta Chats bijdraagmogelijkheden +om te zien hoe je een financiële of andere bijdrage kunt leveren.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/paperclip.png b/src/main/assets/help/paperclip.png new file mode 100644 index 0000000000000000000000000000000000000000..01d4e15f0c25ddafc457a45aee7e920ee3ebd523 Binary files /dev/null and b/src/main/assets/help/paperclip.png differ diff --git a/src/main/assets/help/pl/help.html b/src/main/assets/help/pl/help.html new file mode 100644 index 0000000000000000000000000000000000000000..a01da3e67f674d74aa7c5716b894709ed0b0d862 --- /dev/null +++ b/src/main/assets/help/pl/help.html @@ -0,0 +1,1435 @@ + + + + + +

+ + + Czym jest Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    Musisz zaakceptować prośbę, zanim będziesz mógł odpowiedzieć.

    +
  • +
  • +

    Możesz także usunąć wiadomość, jeśli nie chcesz w tej chwili z nią rozmawiać.

    +
  • +
  • +

    Jeśli usuniesz prośbę, przyszłe wiadomości od tego kontaktu nadal będą wyświetlane jako prośba o wiadomość, więc możesz zmienić zdanie. Jeśli naprawdę nie chcesz otrzymywać wiadomości od tej osoby, rozważ zablokowanie jej.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Czy Delta Chat obsługuje obrazy, filmy i inne załączniki? + + +

+ +
    +
  • +

    Tak. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    Ze względu na wydajność obrazy są domyślnie optymalizowane i wysyłane w mniejszym rozmiarze, ale można je wysłać jako „plik”, aby zachować oryginał.

    +
  • +
+ +

+ + + Czym są profile? Jak mogę przełączać się między nimi? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

Podczas pierwszej instalacji Delta Chat tworzony jest pierwszy profil.

+ +

Później możesz dotknąć swojego zdjęcia profilowego w lewym górnym rogu, aby Dodać profile lub Przełączyć profile.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

Możesz także dowiedzieć się, jak używać tego samego profilu na wielu urządzeniach.

+ +

+ + + Kto widzi moje zdjęcie profilowe? + + +

+ +
    +
  • +

    Możesz dodać zdjęcie profilowe w swoich ustawieniach. Jeśli napiszesz do swoich kontaktów lub dodasz je za pomocą kodu QR, automatycznie zobaczą je jako Twoje zdjęcie profilowe.

    +
  • +
  • +

    Ze względów prywatności nikt nie widzi Twojego zdjęcia profilowego, dopóki nie napiszesz do niego wiadomości.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + Co oznacza przypinanie, wyciszanie i archiwizowanie? + + +

+ +

Użyj tych narzędzi, aby uporządkować swoje czaty i mieć wszystko na swoim miejscu:

+ +
    +
  • +

    Przypięte czaty zawsze pozostają na szczycie listy czatów. Możesz ich używać, aby szybko lub tymczasowo uzyskać dostęp do swoich ulubionych czatów, aby o czymś nie zapomnieć.

    +
  • +
  • +

    Wycisz czaty, jeśli nie chcesz otrzymywać z nich powiadomień. Wyciszone czaty pozostają na swoim miejscu i możesz też przypiąć wyciszony czat.

    +
  • +
  • +

    Archiwizuj czaty, jeśli nie chcesz ich już widzieć na liście czatów. Zarchiwizowane czaty pozostają dostępne nad listą czatów lub poprzez wyszukiwanie.

    +
  • +
  • +

    Gdy zarchiwizowany czat otrzyma nową wiadomość, o ile nie zostanie wyciszony, wyskoczy z archiwum i wróci na twoją listę czatów. +Wyciszone czaty pozostają zarchiwizowane do czasu ich ręcznego przywrócenia.

    +
  • +
+ +

Aby skorzystać z tych funkcji, przytrzymaj dłużej lub kliknij prawym przyciskiem myszy czat na liście czatów.

+ +

+ + + Jak działają „Zapisane wiadomości”? + + +

+ +

Zapisane wiadomości to czat, którego możesz użyć, aby łatwo zapisać i znaleźć wiadomości.

+ +
    +
  • +

    W dowolnym czacie naciśnij i przytrzymaj lub kliknij prawym przyciskiem myszy wiadomość i wybierz Zapisz

    +
  • +
  • +

    Zapisane wiadomości są oznaczone symbolem ikona Zapisz obok znacznika czasu

    +
  • +
  • +

    Później otwórz czat „Zapisane wiadomości” — zobaczysz tam zapisane wiadomości. Naciskając ikona strzałki w prawo, możesz wrócić do oryginalnej wiadomości w oryginalnym czacie

    +
  • +
  • +

    Na koniec możesz również użyć „Zapisz wiadomości”, aby robić osobiste notatki — otwórz czat, wpisz coś, dodaj zdjęcie lub wiadomość głosową itp.

    +
  • +
  • +

    Ponieważ „Zapisane wiadomości” są zsynchronizowane, mogą być bardzo przydatne do przesyłania danych między urządzeniami

    +
  • +
+ +

Wiadomości pozostają zapisane, nawet jeśli zostaną edytowane lub usunięte — może to być przez nadawcę, czyszczenie urządzenia lub znikające wiadomości z innych czatów.

+ +

+ + + Co oznacza zielona kropka? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Co oznaczają znaczniki wyświetlane obok wiadomości wychodzących? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Poprawianie literówek i usuwanie wiadomości po wysłaniu + + +

+ +
    +
  • +

    Możesz edytować tekst wiadomości po wysłaniu. W tym celu naciśnij i przytrzymaj lub kliknij prawym przyciskiem myszy wiadomość i wybierz Edytuj, lubikona Edytuj.

    +
  • +
  • +

    Jeśli wysłałeś wiadomość przypadkowo, z tego samego menu wybierz Usuń, a następnie Usuń u wszystkich.

    +
  • +
+ +

Podczas gdy edytowane wiadomości będą miały słowo „Edytowana” obok znacznika czasu, usunięte wiadomości zostaną usunięte bez znacznika na czacie. Powiadomienia nie są wysyłane i nie ma limitu czasowego.

+ +

Pamiętaj, że oryginalną wiadomość nadal mogą otrzymać członkowie czatu, którzy mogli już odpowiedzieć, przesłać dalej, zapisać, wykonać zrzut ekranu lub w inny sposób skopiować wiadomość.

+ +

+ + + Jak działają znikające wiadomości? + + +

+ +

Możesz włączyć „znikające wiadomości” w ustawieniach czatu, w prawym górnym rogu okna czatu, wybierając przedział czasu od 5 minut do 1 roku.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Pamiętaj, że na znikających wiadomościach możesz polegać tylko wtedy, gdy ufasz swoim partnerom czatu; złośliwi partnerzy czatu mogą robić zdjęcia lub w inny sposób zapisywać, kopiować lub przesyłać dalej wiadomości przed usunięciem.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + Co się stanie, jeśli włączę opcję „Usuń wiadomości z urządzenia”? + + +

+ +
    +
  • Jeśli chcesz zaoszczędzić miejsce na urządzeniu, możesz wybrać opcję automatycznego usuwania starych wiadomości.
  • +
  • Aby ją włączyć, przejdź do „Usuń wiadomości z urządzenia” w ustawieniach w sekcji „Czaty i media”. Możesz ustawić przedział czasowy pomiędzy „po 1 godzinie” a „po 1 roku”; w ten sposób wszystkie wiadomości zostaną usunięte z urządzenia, gdy tylko staną się starsze.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Tworzenie grupy + + +

+ +
    +
  • Wybierz Nowy czat, a następnie Nowa grupa z menu w prawym górnym rogu lub naciśnij odpowiedni przycisk na Androidzie / iOS.
  • +
  • Na następnym ekranie wybierz członków grupy i zdefiniuj nazwę grupy. Możesz też wybrać awatar grupy.
  • +
  • Zaraz po napisaniu pierwszej wiadomości w grupie wszyscy członkowie zostaną poinformowani o nowej grupie i mogą odpowiedzieć w grupie (jeżeli nie napiszesz wiadomości w grupie, grupa jest niewidoczna dla członków).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Usunąłem się przez przypadek. + + +

+ +
    +
  • Ponieważ nie jesteś członkiem grupy, nie możesz dodać siebie ponownie. +Jednak nie ma problemu, po prostu poproś dowolnego członka grupy na normalnym czacie, aby dodał cię ponownie.
  • +
+ +

+ + + Nie chcę już otrzymywać wiadomości od grupy. + + +

+ +
    +
  • +

    Usuń siebie z listy członków lub usuń cały czat. +Jeśli później będziesz chciał ponownie dołączyć do grupy, poproś innego członka grupy, aby dodał cię do grupy.

    +
  • +
  • +

    Alternatywnie możesz też „Wyłączyć powiadomienia” dla grupy dzięki temu otrzymasz wszystkie wiadomości i +nadal będziesz mógł pisać, ale nie będziesz już powiadamiany o żadnych nowych wiadomościach.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Natychmiastowe dostarczanie wiadomości i powiadomienia push + + +

+ +

+ + + Co to są powiadomienia push? Jak mogę uzyskać natychmiastowe dostarczenie wiadomości? + + +

+ +

Powiadomienia push są wysyłane przez „usługi push” Apple i Google do urządzenia użytkownika, dzięki czemu nieaktywna aplikacja Delta Chat może pobierać wiadomości w tle i w razie potrzeby wyświetlać powiadomienia na telefonie użytkownika.

+ +

Powiadomienia push działają na wszystkich włączonych serwerach chatmail

+ +
    +
  • +

    Urządzeń z iOS, poprzez integrację z usługami Apple Push.

    +
  • +
  • +

    Urządzenia z Androidem, poprzez integrację z usługą Google FCM Push, w tym na urządzeniach korzystających z microG zamiast autorskiego kodu Google w telefonie.

    +
  • +
+ +

+ + + Czy powiadomienia push są włączone na urządzeniach z iOS? Czy istnieje alternatywa? + + +

+ +

Tak, Delta Chat automatycznie korzysta z powiadomień push dla profili chatmail +. I nie, w telefonach Apple nie ma alternatywy umożliwiającej natychmiastowe dostarczanie wiadomości, ponieważ urządzenia Apple nie pozwalają Delta Chat na pobieranie danych w tle. Powiadomienia push są automatycznie aktywowane dla użytkowników iOS, ponieważ system prywatności powiadomień Delta Chat nie udostępnia Apple danych, których jeszcze nie posiada.

+ +

+ + + Czy powiadomienia Push są włączone/potrzebne na urządzeniach z Androidem? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

W ustawieniach „Powiadomień” Delta Chat dla „Natychmiastowej dostawy” możesz zmienić następujące ustawienia wpływające na wszystkie profile czatu:

+ +
    +
  • Użyj połączenia w tle: Jeśli nie korzystasz z usługi Push, możesz wyłączyć „optymalizację baterii” dla Delta Chat, umożliwiając mu pobieranie wiadomości w tle. Mogą jednak wystąpić opóźnienia od minut do godzin. Niektórzy dostawcy Androida nawet całkowicie ograniczają aplikacje (zobacz dontkillmyapp.com), a Delta Chat może nie wyświetlać wiadomości przychodzących, dopóki ręcznie ponownie nie otworzysz aplikacji.
  • +
+ +

Wymuś połączenie w tle: Jest to opcja awaryjna, jeśli poprzednie opcje nie są dostępne lub nie zapewniają „natychmiastowej dostawy”. Włączenie tej opcji powoduje stałe powiadomienie na twoim telefonie, które czasami może zostać „minifikowane” w przypadku najnowszych telefonów z Androidem.

+ +

Obie opcje „Połączenia w tle” są energooszczędne i można je bezpiecznie wypróbować, jeśli wiadomości docierają do ciebie z dużym opóźnieniem.

+ +

+ + + Jak prywatne są powiadomienia push na Delta Chat? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Oto jak aplikacje Delta Chat realizują dostarczanie powiadomień push:

+ +
    +
  • +

    Aplikacja Delta Chat uzyskuje lokalnie „token urządzenia”, szyfruje i przechowuje go na serwerze chatmail.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

Centralny serwer proxy powiadomień Delta Chat jest mały i w pełni zaimplementowany w Rust i zapomina o tokenach urządzeń zaraz po ich przetworzeniu przez Apple/Google/itp, zwykle w ciągu kilku milisekund.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

W wyniku tego ogólnego projektu ochrony prywatności nawet przejęcie serwera chatmail lub pełne przejęcie centralnego serwera proxy powiadomień Delta Chat nie spowodowałoby ujawnienia prywatnych informacji, których usługi Push jeszcze nie posiadają.

+ +

+ + + Dlaczego Delta Chat integruje się ze scentralizowanymi, zastrzeżonymi usługami push Apple/Google? + + +

+ +

Delta Chat to darmowy i otwartoźródłowy zdecentralizowany komunikator z możliwością wyboru serwera, ale chcemy, aby użytkownicy mogli niezawodnie doświadczać „natychmiastowego dostarczania” wiadomości, tak jak w przypadku aplikacji Whatsapp, Signal lub Telegram, bez zadawania z góry pytań, które są bardziej odpowiednie dla doświadczonych użytkowników lub programistów.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-klient + + +

+ +

+ + + Czy mogę korzystać z Delta Chat na wielu urządzeniach w tym samym czasie? + + +

+ +

Tak. Możesz używać tego samego profilu na różnych urządzeniach:

+ +
    +
  • +

    Upewnij się, że oba urządzenia są połączone z tego samego Wi-Fi lub sieci

    +
  • +
  • +

    Na pierwszym urządzeniu przejdź do Ustawienia → Dodaj kolejne urządzenie, w razie potrzeby odblokuj ekran i poczekaj chwilę, aż pojawi się kod QR

    +
  • +
  • +

    Na drugim urządzeniu zainstaluj Delta Chat

    +
  • +
  • +

    Na drugim urządzeniu uruchom Delta Chat, wybierz Dodaj jako kolejne urządzenie i zeskanuj kod QR z pierwszego urządzenia

    +
  • +
+ +

Przenoszenie powinno rozpocząć się po kilku sekundach, a podczas przenoszenia oba urządzenia będą pokazywać postęp. Poczekaj, aż zakończy się na obu urządzeniach.

+ +

W przeciwieństwie do wielu innych komunikatorów, po udanym przenoszeniu oba urządzenia są całkowicie niezależne. Jedno urządzenie nie jest potrzebne do działania drugiego.

+ +

+ + + Rozwiązywanie problemów + + +

+ +
    +
  • +

    Sprawdź dokładnie, czy oba urządzenia są w tym samym Wi-Fi lub tej samej sieci

    +
  • +
  • +

    Na Windowsie, przejdź do Panel sterowania / Sieć i internet i upewnij się, że Sieć prywatna jest wybrana jako “Typ profilu sieci” +(po przeniesieniu możesz wrócić do pierwotnej wartości)

    +
  • +
  • +

    W systemie iOS upewnij się, że jest przydzielony dostęp do opcji „Ustawienia » Aplikacje » Delta Chat » Sieć lokalna

    +
  • +
  • +

    W systemie macOS włącz „Preferencje systemowe » Ochrona i prywatność » Sieć lokalna » Delta Chat”

    +
  • +
  • +

    Twój system może mieć „zaporę ogniową”, o której wiadomo, że powoduje problemy (szczególnie w systemie Windows). +Wyłącz zaporę dla Delta Chat po obu stronach i spróbuj ponownie

    +
  • +
  • +

    Sieci dla gości mogą nie pozwalać urządzeniom na komunikację między sobą. Jeśli to możliwe, korzystaj z sieci innej niż gość.

    +
  • +
  • +

    Jeśli nadal masz problemy z korzystaniem z tej samej sieci, spróbuj otworzyć Hotspot na jednym urządzeniu i połączyć się z tą siecią Wi-Fi z drugiego urządzenia

    +
  • +
  • +

    Upewnij się, że na urządzeniu docelowym jest wystarczająca ilość miejsca

    +
  • +
  • +

    Jeśli przenoszenie się rozpoczęło, upewnij się, że urządzenia pozostają aktywne i nie zasypiają. Nie wychodź z Delta Chat. (dokładamy wszelkich starań, aby aplikacja działała w tle, ale systemy mają tendencję do ubijania aplikacji, niestety)

    +
  • +
  • +

    Delta Chat jest już zalogowany na urządzeniu docelowym? Możesz używać wielu kont na urządzeniu, po prostu dodaj kolejne konto

    +
  • +
  • +

    Jeśli nadal masz problemy lub nie możesz zeskanować kodu QR, wypróbuj ręczne przenoszenie opisane poniżej

    +
  • +
+ +

+ + + Ręczny transfer + + +

+ +

Ta metoda jest zalecana tylko wtedy, gdy opisana powyżej opcja „Dodaj kolejne urządzenie” nie działa.

+ +
    +
  • Na starym urządzeniu przejdź do „Ustawienia » Czaty i media » Eksport kopii zapasowej”. Wprowadź swój PIN odblokowania ekranu, wzór lub hasło. Następnie możesz nacisnąć „Utwórz kopię”. Spowoduje to zapisanie pliku kopii zapasowej na urządzeniu. Teraz musisz jakoś przenieść go na inne urządzenie.
  • +
  • Na nowym urządzeniu, na ekranie logowania, zamiast logować się na swoje konto e-mail, wybierz „Przywróć z kopii zapasowej”. Po zaimportowaniu Twoje rozmowy, klucze szyfrujące i multimedia powinny zostać skopiowane na nowe urządzenie. +
      +
    • Jeśli korzystasz z iOS i napotykasz trudności, może ten poradnik Ci pomoże.
    • +
    +
  • +
  • Jesteś teraz zsynchronizowany i możesz używać obu urządzeń do wysyłania i odbierania wiadomości zaszyfrowanych end-to-end w komunikacji ze swoimi partnerami.
  • +
+ +

+ + + Czy są jakieś plany wprowadzenia klienta Web Delta Chat? + + +

+ +
    +
  • Nie ma bezpośrednich planów, ale wstępne przemyślenia.
  • +
  • Istnieją 2-3 możliwości wprowadzenia klienta Web Delta Chat, ale wszystkie wymagają znaczącej pracy. Na razie skupiamy się na udostępnianiu stabilnych wersji we wszystkich sklepach z aplikacjami (repozytoria Google Play/iOS/Windows/macOS/Linux) jako aplikacji natywnych.
  • +
  • Jeśli potrzebujesz klienta Web, ponieważ nie możesz instalować oprogramowania na komputerze, na którym pracujesz, możesz użyć przenośnego klienta Windows Desktop lub AppImage dla Linuxa. Możesz je znaleźć na get.delta.chat.
  • +
+ +

+ + + Zaawansowane + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + Chcę zarządzać własnym serwerem dla Delta Chat. Co polecacie? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Interesują mnie szczegóły techniczne. Możesz powiedzieć mi coś więcej? + + +

+ + + +

+ + + Szyfrowanie i bezpieczeństwo + + +

+ +

+ + + Jakie standardy są stosowane do szyfrowania end-to-end? + + +

+ +

Delta Chat wykorzystuje bezpieczny podzbiór standardu OpenPGP do automatycznego szyfrowania typu end-to-end za pomocą następujących protokołów:

+ +
    +
  • +

    Secure-Join do wymiany informacji o konfiguracji szyfrowania poprzez skanowanie kodów QR lub „linki zaproszeń”.

    +
  • +
  • +

    Autocrypt służy do automatycznego ustanawiania szyfrowania typu end-to-end między kontaktami a wszystkimi członkami czatu grupowego.

    +
  • +
  • +

    Udostępnienie kontaktu na czacie umożliwia odbiorcom korzystanie z szyfrowania typu end-to-end z tym kontaktem.

    +
  • +
+ +

Delta Chat nie wysyła zapytań, nie publikuje ani nie wchodzi w interakcję z żadnymi serwerami kluczy OpenPGP.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

Wszystkie wiadomości w Delta Chat są domyślnie szyfrowane metodą end-to-end. Od wydania Delta Chat w wersji 2 (lipiec 2025 r.) nie ma już blokad ani podobnych znaczników na wiadomościach szyfrowanych metodą end-to-end.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

Jeśli korzystasz z domyślnych przekaźników chatmail, odbieranie ani wysyłanie wiadomości bez szyfrowania end-to-end jest niemożliwe.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + Co oznacza zielony znacznik wyboru w profilu kontaktu? + + +

+ +

Profil kontaktu może wyświetlać zielony znacznik wyboru green checkmark i wiersz „Zweryfikowano przez…”. Każdy kontakt oznaczony zielonym znacznikiem albo wykonał z tobą bezpośrednie skanowanie QR, albo został zweryfikowany przez inny kontakt oznaczony zielonym znacznikiem. Weryfikacje odbywają się automatycznie podczas dodawania członków do grup. Osoba, która doda kontakt oznaczony zielonym znacznikiem wyboru do grupy zawierającej wyłącznie członków oznaczonych zielonym znacznikiem wyboru, staje się osobą weryfikującą. W profilu kontaktu możesz wielokrotnie dotykać tekstu „Zweryfikowano przez…”, aż dojdziesz do osoby, z którą bezpośrednio wykonałeś skanowanie QR.

+ +

Aby uzyskać bardziej szczegółowe omówienie „gwarantowanego szyfrowania typu end-to-end”, zobacz Protokoły Secure-Join, a w szczególności przeczytaj o „zweryfikowanych grupach”, technicznym określeniu tak zwanego tutaj „zielonego znacznika wyboru” lub „gwarantowanego szyfrowania end-to-end” czatów.

+ +

+ + + Czy załączniki (zdjęcia, pliki, pliki audio itp.) są szyfrowane metodą end-to-end? + + +

+ +

Tak.

+ +

Kiedy mówimy o „wiadomości zaszyfrowanej metodą end-to-end”, zawsze mamy na myśli, że zaszyfrowana jest cała wiadomość, łącznie ze wszystkimi załącznikami i metadanymi załączników, takimi jak nazwy plików.

+ +

+ + + Czy OpenPGP jest bezpieczny? + + +

+ +

Tak, Delta Chat korzysta z bezpiecznego podzbioru OpenPGP, który wymaga prawidłowego zaszyfrowania i podpisania całej wiadomości. Na przykład „Odłączone podpisy” nie są traktowane jako bezpieczne.

+ +

OpenPGP samo w sobie nie jest niebezpieczne. Większość publicznie omawianych problemów związanych z bezpieczeństwem OpenPGP tak naprawdę wynika ze złej użyteczności lub złej implementacji narzędzi, lub aplikacji (lub obu). Szczególnie ważne jest rozróżnienie pomiędzy OpenPGP, standardem szyfrowania IETF, a GnuPG (GPG), narzędziem wiersza poleceń implementującym OpenPGP. Wiele publicznych komentarzy krytycznych na temat OpenPGP tak naprawdę omawia GnuPG, którego Delta Chat nigdy nie używał. Delta Chat korzysta raczej z implementacji OpenPGP Rust rPGP, dostępnej jako niezależny pakiet „pgp” i poddanej audytowi bezpieczeństwa w 2019 i 2024 roku.

+ +

Naszym celem, wraz z innymi wdrażającymi OpenPGP, jest dalsza poprawa parametrów bezpieczeństwa poprzez wdrożenie nowego IETF OpenPGP Crypto-Refresh, który na szczęście został przyjęty latem 2023 roku.

+ +

+ + + Czy rozważałeś użycie alternatyw dla OpenPGP do szyfrowania typu end-to-end? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat przyjmuje holistyczne podejście do „użytecznego bezpieczeństwa” i współpracuje z szeroką gamą grup aktywistów, a także renomowanymi badaczami, takimi jak TeamUSEC, aby poprawić rzeczywiste wyniki użytkowników przed zagrożeniami bezpieczeństwa. Protokół przewodowy i standard ustanawiania szyfrowania end-to-end to tylko jedna część „wyników użytkownika”. Zobacz także nasze odpowiedzi na pytania dotyczące przejęcia urządzenia i metadanych wiadomości.

+ +

+ + + Czy Delta Chat jest podatny na EFAIL? + + +

+ +

Nie, Delta Chat nigdy nie był podatny na atak EFAIL, ponieważ jego implementacja OpenPGP rPGP używa kodu wykrywania modyfikacji podczas szyfrowania wiadomości i zwraca błąd, jeśli kod wykrywania modyfikacji jest nieprawidłowy.

+ +

Delta Chat również nigdy nie był podatny na atak EFAIL „Direct Exfiltration”, ponieważ odszyfrowuje jedynie wiadomości multipart/encrypted, które zawierają dokładnie jedną zaszyfrowaną i podpisaną część, zgodnie ze specyfikacją Autocrypt Level 1.

+ +

+ + + Czy wiadomości oznaczone ikoną poczty są widoczne w internecie? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + W jaki sposób Delta Chat chroni metadane w wiadomościach? + + +

+ +

W przeciwieństwie do większości innych komunikatorów, aplikacje Delta Chat nie przechowują żadnych metadanych dotyczących kontaktów ani grup na serwerach, również w formie zaszyfrowanej. Zamiast tego wszystkie metadane grup są szyfrowane metodą end-to-end i przechowywane wyłącznie na urządzeniach użytkowników końcowych.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

Wszystkie pozostałe metadane dotyczące wiadomości, kontaktów i grup znajdują się w zaszyfrowanej metodą end-to-end części wiadomości.

+ +

+ + + Jak chronić metadane i kontakty w przypadku przejęcia urządzenia? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Czy Delta Chat obsługuje funkcję „Sealed Sender”? + + +

+ +

Nie, jeszcze nie.

+ +

Komunikator Signal wprowadził funkcję „Sealed Sender” w 2018 roku, aby infrastruktura serwerowa nie wiedziała, kto wysyła wiadomość do grupy odbiorców. Jest to szczególnie ważne, ponieważ serwer Signal zna numer telefonu komórkowego każdego konta, który zazwyczaj jest powiązany z identyfikatorem paszportu.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Czy Delta Chat obsługuje funkcję Perfect Forward Secrecy? + + +

+ +

Nie, jeszcze nie.

+ +

Delta Chat obecnie nie obsługuje mechanizmu Perfect Forward Secrecy (PFS). Oznacza to, że jeśli twój prywatny klucz deszyfrujący zostanie ujawniony, a ktoś zdobędzie twoje wcześniejsze wiadomości w trakcie transmisji, będzie mógł je odszyfrować i odczytać za pomocą ujawnionego klucza deszyfrującego. Należy pamiętać, że mechanizm Forward Secrecy zwiększa bezpieczeństwo tylko w przypadku usuwania wiadomości. W przeciwnym razie osoba, która uzyska twoje klucze deszyfrujące, zazwyczaj będzie mogła uzyskać dostęp do wszystkich nieusuniętych wiadomości i nie będzie musiała odszyfrowywać żadnych wcześniej zebranych wiadomości.

+ +

Opracowaliśmy metodę Forward Secrecy, która przeszła wstępną analizę niektórych kryptografów i ekspertów ds. wdrożeń, ale oczekuje na bardziej formalne opracowanie, które potwierdzi jej niezawodne działanie w federacyjnym przesyłaniu wiadomości i w przypadku korzystania z wielu urządzeń, zanim zostanie zaimplementowana w rdzeniu chatmail, co uczyniłoby ją dostępną we wszystkich klientach chatmail.

+ +

+ + + Czy Delta Chat obsługuje kryptografię postkwantową? + + +

+ +

Nie, jeszcze nie.

+ +

Delta Chat korzysta z biblioteki Rust OpenPGP rPGP, która obsługuje najnowszy projekt OpenPGP IETF Post-Quantum-Cryptography. Planujemy dodać obsługę PQC do rdzenia chatmail po sfinalizowaniu projektu w IETF we współpracy z innymi implementatorami OpenPGP.

+ +

+ + + Jak mogę ręcznie sprawdzić informacje o szyfrowaniu? + + +

+ +

Możesz sprawdzić stan szyfrowania end-to-end ręcznie w oknie dialogowym „Szyfrowanie” (profil użytkownika w systemie Android/iOS lub kliknij prawym przyciskiem myszy element listy czatu użytkownika na komputerze). Delta Chat pokazuje tam dwa odciski palców. Jeśli te same odciski palców pojawią się u ciebie i urządzeniu twojego kontaktu, połączenie jest bezpieczne.

+ +

+ + + Czy mogę ponownie wykorzystać mój istniejący klucz prywatny? + + +

+ +

Nie.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Czy Delta Chat był niezależnie kontrolowany pod kątem luk w zabezpieczeniach? + + +

+ +

Tak, wielokrotnie. +Projekt Delta Chat stale przechodzi niezależne audyty bezpieczeństwa i analizy, +od najnowszych do najstarszych:

+ +

W grudniu 2024 r. NLNET wykonała ocenę rPGP zleconą przez Radically Open Security. rPGP służy jako kompleksowy silnik szyfrowania OpenPGP w Delta Chat. Wydano dwa ostrzeżenia związane z wynikami tego audytu:

+ + + +

Problemy opisane w tych ostrzeżeniach zostały naprawione i są częścią wydań Delta Chat we wszystkich sklepach z aplikacjami od grudnia 2024 r.

+ +
    +
  • +

    W marcu 2024 r. otrzymaliśmy dogłębną analizę bezpieczeństwa od grupy badawczej ds. kryptografii stosowanej w ETH Zuerich i zajęliśmy się wszystkimi poruszonymi kwestiami. Więcej szczegółowych informacji można znaleźć na naszym blogu na temat szyfrowania typu End-to-End z gwarancją Hardening oraz opublikowanym później artykule badawczym Cryptographic Analysis of Delta Chat.

    +
  • +
  • +

    W kwietniu 2023 r. naprawiliśmy problemy z bezpieczeństwem i prywatnością w funkcji „aplikacje internetowe udostępniane na czacie”, związane z awariami piaskownicy, szczególnie w przypadku Chromium. Następnie przeprowadziliśmy niezależny audyt bezpieczeństwa od Cure53 i wszystkie wykryte problemy zostały naprawione w aplikacji z serii 1.36 wydanej w kwietniu 2023 r. Pełną historię bezpieczeństwa end-to-end w sieci można znaleźć tutaj.

    +
  • +
  • +

    W marcu 2023 r. firma Cure53 przeanalizowała zarówno szyfrowanie transportu połączeń sieciowych Delta Chat, jak i powtarzalną konfigurację serwera pocztowego zgodnie z zaleceniami na tej stronie. Możesz przeczytać więcej o audycie na naszym blogu lub przeczytać pełny raport tutaj.

    +
  • +
  • +

    W 2020 r. firma Include Security przeanalizowała biblioteki Rust core, IMAP, SMTP i TLS Delta Chat. Nie znalazła żadnych problemów krytycznych ani poważnych. W raporcie zwrócono uwagę na kilka słabych punktów o średniej wadze – same w sobie nie stanowią zagrożenia dla użytkowników Delta Chat, ponieważ zależą od środowiska, w którym używany jest Delta Chat. Ze względu na użyteczność i kompatybilność nie możemy złagodzić wszystkich z nich i zdecydowaliśmy się przedstawić zalecenia dotyczące bezpieczeństwa zagrożonym użytkownikom. Pełny raport można przeczytać tutaj.

    +
  • +
  • +

    W 2019 r. firma Include Security przeanalizowała biblioteki PGP i RSA Delta Chat. Nie znaleziono żadnych krytycznych problemów, ale dwa poważne problemy, które później naprawiliśmy. Ujawniła również jeden problem o średniej wadze i kilka mniej poważnych, ale nie było możliwości wykorzystania tych luk w implementacji Delta Chat. Niektóre z nich jednak naprawiliśmy od czasu zakończenia kontroli. Pełny raport można przeczytać tutaj.

    +
  • +
+ +

+ + + Różne + + +

+ +

+ + + Jakich uprawnień potrzebuje Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Gdzie moi znajomi mogą znaleźć Delta Chat? + + +

+ +

Delta Chat jest dostępny na wszystkich głównych i niektórych mniejszych platformach:

+ + + +

+ + + W jaki sposób finansowany jest rozwój Delta Chat? + + +

+ +

Delta Chat nie otrzymuje żadnego kapitału wysokiego ryzyka, nie jest zadłużony i nie jest pod presją generowania ogromnych zysków lub sprzedawania reklamodawcom użytkowników i ich przyjaciół oraz rodziny (lub gorzej). +Raczej korzystamy z publicznych źródeł finansowania, jak dotąd pochodzących z UE i USA, aby wspomóc nasze wysiłki w inicjowaniu zdecentralizowanego i zróżnicowanego ekosystemu komunikatora, opartego na rozwoju społeczności Free i Open-Source.

+ +

Konkretnie, rozwój Delta Chat był dotychczas finansowany z tych źródeł, uporządkowanych chronologicznie:

+ +
    +
  • +

    Unijny projekt NEXTLEAP sfinansował badania i wdrożenie zweryfikowanych grup i ustawień protokołów kontaktowych w latach 2017 i 2018, a także pomógł zintegrować szyfrowanie end-to-end poprzez Autocrypt.

    +
  • +
  • +

    Open Technology Fund przyznał nam pierwszy grant w 2018/2019 (~200 000 $), dzięki któremu znacznie ulepszyliśmy aplikację na Androida i wydaliśmy pierwszą wersję beta aplikacji na komputery stacjonarne, a także ugruntował rozwój naszych funkcji w badaniach UX w kontekście praw człowieka, zobacz nasz końcowy raport Needfinding and UX. +Druga dotacja w 2019/2020 (~300 000 4) pomogła nam wydać wersje Delta/iOS, przekonwertować naszą podstawową bibliotekę na Rust i zapewnić nowe funkcje dla wszystkich platform.

    +
  • +
  • +

    Fundacja NLnet przekazała w latach 2019/2020 kwotę 46 tys. EUR na wykonanie wiązań Rust/Python i uruchomienie ekosystemu Chat-bot.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    W latach 2023-2024 pomyślnie ukończyliśmy finansowany przez OTF projekt Secure Chatmail, co pozwoliło nam wprowadzić gwarantowane szyfrowanie, stworzyć sieć serwerów chatmail i zapewnić „natychmiastowe wdrażanie” we wszystkich aplikacjach wydanych od kwietnia 2024 r.

    +
  • +
  • +

    W latach 2023 i 2024 zostaliśmy przyjęci do programu Next Generation Internet (NGI) za naszą pracę w webxdc PUSH, wraz z partnerami współpracującymi pracującymi nad webxdc evolve, webxdc XMPP, DeltaTouch i DeltaTauri. Wszystkie te projekty są częściowo ukończone lub zostaną ukończone na początku 2025 r.

    +
  • +
  • +

    Czasami otrzymujemy jednorazowe darowizny od osób prywatnych. Na przykład w 2021 roku pewna hojna osoba przekazała nam 4K EUR w formie przelewu bankowego tytułem “kontynuujcie dobry rozwój!”. 💜 Takie pieniądze przeznaczamy na finansowanie spotkań rozwojowych lub na doraźne wydatki, których nie da się łatwo przewidzieć lub zrefundować z publicznych dotacji. Otrzymywanie większej ilości darowizn pomaga nam również stać się bardziej niezależnymi i długoterminowo rentownymi jako społeczność współpracowników.

    + + +
  • +
  • +

    Wreszcie, ale zdecydowanie nie najmniej ważne, kilku ekspertów i entuzjastów pro-bono wniosło wkład i przyczyniło się do rozwoju Delta Chat bez otrzymywania pieniędzy lub tylko niewielkich kwot. Bez nich Delta Chat nie byłby tam, gdzie jest dzisiaj, nawet w pobliżu.

    +
  • +
+ +

Wspomniane powyżej finansowanie pieniężne jest w większości organizowane przez merlinux GmbH we Freiburgu (Niemcy) i jest dystrybuowane do kilkunastu podmiotów na całym świecie.

+ +

Zapoznaj się z kanałami wpłat dla Delta Chat, aby uzyskać informacje o możliwościach wpłat zarówno pieniężnych, jak i innych.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/pt/help.html b/src/main/assets/help/pt/help.html new file mode 100644 index 0000000000000000000000000000000000000000..0333d189f98cfe2e199c192c380b2113023a68ad --- /dev/null +++ b/src/main/assets/help/pt/help.html @@ -0,0 +1,1664 @@ + + + + + +

+ + + O que é o Delta Chat + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    You need to accept the request before you can reply.

    +
  • +
  • +

    You can also delete it if you don’t want to chat with them for now.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Dá para mandar imagens, vídeos e outros anexos pelo Delta Chat? + + +

+ +
    +
  • +

    Sim. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Quem consegue ver a imagem do meu perfil? + + +

+ +
    +
  • +

    Você pode adicionar uma imagem de perfil nas suas configurações. Se você escrever aos seus contatos ou adicioná-los via código QR, eles automaticamente verão a imagem do seu perfil.

    +
  • +
  • +

    Por motivos de privacidade, ninguém pode ver a imagem do seu ṕerfil até que você escreva para as pessoas.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + What do Pinning, Muting and Archiving mean? + + +

+ +

Use these tools to organize your chats and keep everything in its place:

+ +
    +
  • +

    Pinned chats always stay atop of the chat list. You can use them to access your most loved chats quickly or temporarily to not forget about things.

    +
  • +
  • +

    Mute chats if you do not want to get notifications for them. Muted chats stay in place and you can also pin a muted chat.

    +
  • +
  • +

    Archive chats if you do not want to see them in your chat list any longer. +Archived chats remain accessible above the chat list or via search.

    +
  • +
  • +

    When an archived chat gets a new message, unless muted, it will pop out of the archive and back into your chat list. +Muted chats stay archived until you unarchive them manually.

    +
  • +
+ +

To use the functions, long tap or right click a chat in the chat list.

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + What does the green dot mean? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + O que significam os carrapatos mostrados ao lado das mensagens de saída? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + What happens if I turn on “Delete old messages from device”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Criação de um grupo + + +

+ +
    +
  • Selecione Nova Conversa e em seguida Novo Grupo no menu que fica na parte de cima da tela, no canto direito, ou clique no botão correspondente no ANdroid/iOS.
  • +
  • Na tela seguinte, selecione os membros do grupo e defina o nome do grupo. Você também pode selecionar o avatar do grupo (uma imagem).
  • +
  • Logo após você escrever a primeira mensagem, todas as pessoas do grupo serão informadas sobre o novo grupo e poderão responder no grupo (a não que você escreva uma mensagem ali, o grupo estará invisível para os membros).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Deletei minha própria conta por acidente. + + +

+ +
    +
  • Já que você não é mais um membro do grupo, não tem como se adicionar novamente. Entretanto, não tem problema, é só pedir para outra pessoa do grupo, através de um chat normal, adicionar você.
  • +
+ +

+ + + Não quero mais receber as mensagens de um grupo. + + +

+ +
    +
  • +

    Ou você se exclui do grupo ou apaga a conversa inteira do grupo. +Se você quiser entrar mais tarde no grupo novamente, peça a outra pessoa do grupo para adicioná-la novamente.

    +
  • +
  • +

    Uma alternativa é “silenciar” um grupo. Fazendo isso, você receberá todas as mensagens e ainda poderá escrever, mas não será receberá mais notificações d enovas mensagens.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-cliente + + +

+ +

+ + + Posso usar o Delta Chat em vários dispositivos ao mesmo tempo? + + +

+ +

Sim. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    On the second device, install Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    Transfer should start after a few seconds and during transfer both devices will show the progress. +Wait until it is finished on both devices.

    +
  • +
+ +

In contrast to many other messengers, after successful transfer, +both devices are completely independent. +One device is not needed for the other to work.

+ +

+ + + Troubleshooting + + +

+ +
    +
  • +

    Double-check both devices are in the same Wi-Fi or network

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Ensure there is enough storage on the destination device

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Existe algum plano para a introdução de um cliente Web para Delta Chat? + + +

+ +
    +
  • Não há planos imediatos, mas algumas reflexões preliminares.
  • +
  • Há 2-3 vias para a introdução de um cliente Delta Chat Web, mas todas são +trabalho significativo. Por enquanto, nos concentramos em obter lançamentos estáveis em todos os +app stores (Google Play/iOS/Windows/macOS/Linux repositórios) como aplicativos nativos.
  • +
  • Se você precisa de um Cliente Web, porque não está autorizado a instalar software em +o computador com o qual você trabalha, você pode usar o Windows Desktop Client portátil, +ou o AppImage para Linux. Você pode encontrá-los em +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Estou interessado nos detalhes técnicos. Pode me dizer mais? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Sim.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Posso reutilizar minha chave privada existente? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Was Delta Chat independently audited for security vulnerabilities? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Diverso + + +

+ +

+ + + Quais permissões o Delta Chat precisa? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Como são os desenvolvimentos do Delta Chat financiados? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Sometimes we receive one-time donations from private individuals. +For example, in 2021 a generous individual bank-wired us 4K EUR +with the subject “keep up the good developments!”. 💜 +We use such money to fund development gatherings or to care for ad-hoc expenses +that can not easily be predicted for, or reimbursed from, public funding grants. +Receiving more donations also helps us to become more independent and long-term viable +as a contributor community.

    + + +
  • +
  • +

    Por último, mas não menos importante, vários especialistas pró-bono e entusiastas contribuíram +e contribuir aos desenvolvimentos do Delta Chat sem receber dinheiro, ou apenas +pequenas quantidades. Sem elas, o Delta Chat não estaria onde está hoje, não +mesmo perto.

    +
  • +
+ +

The monetary funding mentioned above is mostly organized by merlinux GmbH in +Freiburg (Germany), and is distributed to more than a dozen contributors world-wide.

+ +

Please see Delta Chat Contribution channels +for both monetary and other contribution possibilities.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/qr-icon.png b/src/main/assets/help/qr-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b012184661df7ec8c177040752045dee8d6d23b3 Binary files /dev/null and b/src/main/assets/help/qr-icon.png differ diff --git a/src/main/assets/help/ru/help.html b/src/main/assets/help/ru/help.html new file mode 100644 index 0000000000000000000000000000000000000000..fb5339d39e073d73443e06307e3be50ad75b70f7 --- /dev/null +++ b/src/main/assets/help/ru/help.html @@ -0,0 +1,1659 @@ + + + + + +

+ + + Что такое Delta Chat? + + +

+ +

Delta Chat — надежное, децентрализованное и безопасное приложение для мгновенных сообщений, +доступное для мобильных и настольных платформ.

+ +
    +
  • +

    Мгновенное создание приватных профилей чата +с безопасными и совместимыми релеями chatmail +которые обеспечивают мгновенную доставку сообщений, а также Push-уведомления для устройств iOS и Android.

    +
  • +
  • +

    Повсеместная поддержка мультипрофиля и +поддержка multi-device на всех платформах +и между различными приложениями chatmail.

    +
  • +
  • +

    Интерактивные приложения чата для игр и совместной работы

    +
  • +
  • +

    Проверенное сквозное шифрование +защита от сетевых и серверных атак.

    +
  • +
  • +

    Бесплатное программное обеспечение с открытым исходным кодом, как на стороне приложений, так и на стороне сервера, +построенное на Интернет-стандартах.

    +
  • +
+ +

+ + + Как найти людей для общения? + + +

+ +

Во-первых, обратите внимание, что Delta Chat — это приватный мессенджер. +Публичного поиска контактов нет, вы сами решаете, с кем общаться.

+ +
    +
  • +

    Если вы находитесь рядом с другом или членом семьи, +нажмите значок QR-код +на главном экране.
    +Попросите вашего собеседника отсканировать QR-код +с помощью приложения Delta Chat.

    +
  • +
  • +

    Для удалённой настройки контакта, +на том же экране, +нажмите “Копировать” или “Поделиться” и отправьте ссылку-приглашение +через другой приватный чат.

    +
  • +
+ +

Теперь подождите, пока устанавливается соединение.

+ +
    +
  • +

    Если оба пользователя онлайн, вскоре они увидят чат +и смогут начать безопасное общение.

    +
  • +
  • +

    Если один из пользователей не в сети или имеет плохое соединение, +возможность общаться появится после восстановления соединения.

    +
  • +
+ +

Поздравляем! +Теперь вы будете автоматически использовать сквозное шифрование с этим контактом. +Если вы добавите друг друга в группы, сквозное шифрование будет установлено между всеми участниками.

+ +

+ + + Почему чат помечен как “Запрос”? + + +

+ +

Поскольку это приватный мессенджер, +писать вам могут только друзья и члены семьи, с которыми вы поделились QR-кодом или ссылкой-приглашением.

+ +

Ваши друзья могут поделиться вашим контактом с другими друзьями, это отображается как запрос.

+ +

— Нужно принять запрос, прежде чем ответить.

+ +

— Вы также можете удалить его, если не хотите общаться с этим человеком.

+ +
    +
  • Если вы удалите запрос, будущие сообщения от этого контакта все равно будут появляться +как запрос сообщения, так что вы можете передумать. Если вы действительно не хотите +получать сообщения от этого человека, подумайте о его блокировке.
  • +
+ +

+ + + Как я могу связать двух своих друзей друг с другом? + + +

+ +

Прикрепите первый контакт к чату второго используя кнопку Paperclip Вложение → Контакт. +Вы также можете добавить небольшое вступительное сообщение.

+ +

Второй контакт получит карточку +на которую можно нажать, чтобы начать общение с первым контактом.

+ +

+ + + Поддерживает ли Delta Chat изображения, видео и другие вложения? + + +

+ +
    +
  • +

    Да. Изображения, видео, файлы, голосовые сообщения и т.д. можно отправлять с помощью кнопок Paperclip Вложение +или Microphone Голосовое сообщение.

    +
  • +
  • +

    Для лучшей производительности изображения по умолчанию оптимизируются и отправляются в меньшем размере, но вы можете отправить их как “файл”, чтобы сохранить оригинал.

    +
  • +
+ +

+ + + Что такое профили? Как я могу переключатся между ними? + + +

+ +

Профиль - это имя, фотография и некоторая дополнительная информация для шифрования сообщений. +Профиль живет только на вашем устройстве (устройствах) +и использует сервер только для передачи сообщений.

+ +

При первой установке Delta Chat создаётся первый профиль.

+ +

Позже, вы можете нажать на изображение вашего профиля в верхнем левом углу, чтобы Добавить профили +или Сменить профили.

+ +

Возможно, вы захотите использовать разные профили для политической деятельности, общения с семьёй или работы.

+ +

Вы также можете изучить как использовать один и тот же профиль на нескольких устройствах.

+ +

+ + + Кто видит изображение моего профиля? + + +

+ +
    +
  • +

    Вы можете добавить изображение профиля в настройках. Если вы пишете своим контактам +или добавляете их с помощью QR-кода, они автоматически видят его как изображение вашего профиля.

    +
  • +
  • +

    По соображениям конфиденциальности, никто не увидит изображение вашего профиля, +пока вы не напишете им сообщение.

    +
  • +
+ +

+ + + Могу ли я установить статус/подпись в Delta Chat? + + +

+ +

Да, +вы можете сделать это в разделе Настройки → Профиль → О себе. +После отправки сообщения контакту, +они увидят его при просмотре ваших контактных данных.

+ +

+ + + Что означают: Закрепить, Отключить уведомления и Отправить в архив? + + +

+ +

Используйте эти инструменты, чтобы организовать чаты и поддерживать порядок во всём:

+ +
    +
  • +

    Закрепленные чаты всегда находятся наверху списка чатов. Вы можете использовать их чтобы быстро получать доступ к вашим любимым чатам или чтобы не забыть о важных вещах.

    +
  • +
  • +

    Отключить уведомления в чатах необходимо, если вы не хотите получать уведомления от них. Чаты с отключенными уведомлениями остаются на месте, и вы также можете закрепить такой чат.

    +
  • +
  • +

    Отправить в архив необходимо, если вы не хотите больше видеть их в списке чатов. +Архивные чаты остаются доступными над списком чатов или через поиск.

    +
  • +
  • +

    Когда в чат, находящийся в архиве, приходит новое сообщение, если не включена опция Отключить уведомления, он Возвращается из архива в ваш список чатов. +Чаты с Отключенными уведомлениями остаются в архиве до тех пор, пока вы не разархивируете их вручную.

    +
  • +
+ +

Чтобы использовать функции, нажмите долгим нажатием или щелкните правой кнопкой мыши по чату в списке чатов.

+ +

+ + + Как работают “Сохраненные сообщения”? + + +

+ +

Сохраненные сообщения - это чат, который можно использовать, чтобы легко запоминать и находить сообщения.

+ +
    +
  • +

    В любом чате нажмите и удерживайте или щелкните правой кнопкой мыши на сообщении и выберите Сохранить

    +
  • +
  • +

    Сохраненные сообщения отмечаются символом +Иконка сохранения +рядом с меткой времени

    +
  • +
  • +

    Затем, зайдите в чат “Сохраненные сообщения” - и вы увидите там все сохраненные сообщения. +Нажав Значок со стрелкой вправо, +вы можете вернуться к этому сообщению в исходном чате

    +
  • +
  • +

    Наконец, вы также можете использовать “Сохраненные сообщения” для создания личных заметок - откройте чат, введите что-то, добавьте фото или голосовое сообщение и т.д.

    +
  • +
  • +

    Поскольку “Сохраненные сообщения” синхронизируются, они могут стать удобным способом передачи данных между устройствами

    +
  • +
+ +

Сообщения остаются сохраненными, даже если они были отредактированы или удалены - +будь то отправителем, очисткой устройства или исчезающими сообщениями из других чатов.

+ +

+ + + Что означает зеленая точка? + + +

+ +

Иногда рядом с аватаром контакта можно увидеть зелёную точку. +Это означает, что вы недавно видели этого человека в течение последних 10 минут, +например, потому что он вам написал или отправил подтверждение прочтения.

+ +

Таким образом, это не индикатор онлайн-статуса в реальном времени +и другие пользователи не всегда будут видеть, что вы “онлайн”.

+ +

+ + + Что означают галочки рядом с исходящими сообщениями? + + +

+ +
    +
  • +

    Одна галочка +означает, что сообщение было успешно отправлено вашему провайдеру.

    +
  • +
  • +

    Две галочки +означают, что по крайней мере одно устройство получателя +сообщило об успешном получении сообщения.

    +
  • +
  • +

    Получатели могли отключить подтверждения прочтения, +поэтому даже если вы видите только одну галочку, сообщение могло быть прочитано.

    +
  • +
  • +

    И наоборот, две галочки не обязательно означают +что человек прочитал или понял сообщение ;)

    +
  • +
+ +

+ + + Исправление опечаток и удаление сообщений после отправки + + +

+ +
    +
  • +

    Вы можете редактировать текст сообщений после отправки. +Для этого нажмите и удерживайте или щелкните правой кнопкой мыши сообщение и выберите Редактировать +или Редактировать.

    +
  • +
  • +

    Если вы отправили сообщение случайно, +в том же меню выберите Удалить и затем Удалить для всех.

    +
  • +
+ +

При этом отредактированные сообщения будут иметь слово “Отредактировано” рядом с временной меткой, +удаленные сообщения будут удалены без каких-либо маркеров в чате. +Уведомления не отправляются, и нет ограничений по времени.

+ +

Обратите внимание, что исходное сообщение все еще может быть получено участниками чата +которые могли уже ответить, переслать, сохранить, сделать скриншот или иным образом скопировать сообщение.

+ +

+ + + Как работают исчезающие сообщения? + + +

+ +

Вы можете включить “исчезающие сообщения” +в настройках чата, +в правом верхнем углу окна чата, +выбрав временной интервал +от 5 минут до 1 года.

+ +

Пока эта настройка не будет отключена, +приложение Delta Chat каждого участника чата позаботится +об удалении сообщений +по истечении выбранного периода времени. +Отсчёт времени начинается +с момента первого просмотра сообщения получателем в Delta Chat. +Сообщения будут удалены +как на серверах, +так и в самих приложениях.

+ +

Обратите внимание, что на исчезающие сообщения можно полагаться +только до тех пор, пока вы доверяете своим собеседникам; +злонамеренные собеседники могут делать фотографии, +или иным образом сохранить, скопировать или переслать сообщения перед удалением.

+ +

Кроме того, +если один из собеседников удалит Delta Chat, +(зашифрованные) сообщения могут дольше оставаться на сервере перед удалением.

+ +

+ + + Что произойдет, если я включу функцию “Удалять старые сообщения с устройства”? + + +

+ +
    +
  • Если вы хотите сэкономить место на устройстве, вы можете выбрать +автоматическое удаление старых сообщений.
  • +
  • Чтобы включить эту функцию, перейдите в “Удалять сообщения с устройства” в настройках “Чаты и медиафайлы” +Вы можете установить период от “Через 1 час” до “Через 1 год”; +Таким образом, все сообщения будут удалены с устройства, как только они станут старше выбранного срока.
  • +
+ +

+ + + Как удалить свой профиль в чате? + + +

+ +

Если вы используете несколько профилей чата, +вы можете удалить отдельные из них в верхнем переключателе профилей (на Android и iOS), +или в боковой панели щелчком правой кнопкой мыши (в настольном приложении). +Профили чата удаляются только с устройства, на котором была запущена операция удаления. +Профили чата на других устройствах будут продолжать полностью функционировать.

+ +

Если вы используете один профиль чата по умолчанию, вы можете просто удалить приложение. +Это автоматически инициирует удаление всех связанных данных об адресе на сервере chatmail. +Подробную информацию смотрите на странице nine.testrun.org address-deletion +или на соответствующей странице выбранного вами стороннего сервера chatmail.

+ +

+ + + Группы + + +

+ +

Группы позволяют нескольким пользователям общаться друг с другом конфиденциально на равных правах.

+ +

Любой может +изменить название группы или аватар, +добавить или удалить участников, +включить исчезающие сообщения, +и удалять собственные сообщения со всех устройств участников.

+ +

Поскольку все участники имеют одинаковые права, группы лучше всего подходят для общения с проверенными друзьями и семьёй.

+ +

+ + + Создание группы + + +

+ +
    +
  • Выберите Новый чат, а затем Новая группа из меню в правом верхнем углу или нажмите соответствующую кнопку на Android/iOS.
  • +
  • На следующем экране выберите участников и придумайте название группы. Вы также можете выбрать изображение группы.
  • +
  • Как только вы напишете первое сообщение в группе, все участники будут проинформированы о новой группе и смогут ответить. (Пока вы не напишете сообщение в группе, группа будет невидима для участников).
  • +
+ +

+ + + Добавление и удаление участников + + +

+ +
    +
  • +

    У всех участников группы одинаковые права. +Поэтому каждый может удалить любого участника или добавить новых.

    +
  • +
  • +

    Чтобы добавлять или удалять участников, коснитесь названия группы в чате и выберите участника, которого нужно добавить или удалить.

    +
  • +
  • +

    Если участника еще нет в вашем списке контактов, но он находится с вами лично, +на том же экране покажите QR-код.
    +Попросите его отсканировать QR-код приложением Delta Chat, нажав + на главном экране.

    +
  • +
  • +

    Для удалённого добавления участника, +нажмите “Копировать” или “Поделиться” и отправьте ссылку-приглашение +через другой приватный чат новому участнику.

    +
  • +
+ +

QR-код и ссылку-приглашение можно использовать для добавления нескольких участников. +Однако, поскольку группы предназначены для проверенных людей, не распространяйте их публично.

+ +

+ + + Я случайно удалил самого себя. + + +

+ +
    +
  • Поскольку вы больше не являетесь участником группы, вы не можете добавлять себя снова. +Однако, это не проблема, просто попросите любого другого участника группы в обычном чате добавить вас снова.
  • +
+ +

+ + + Я больше не хочу получать сообщения группы. + + +

+ +
    +
  • +

    Либо удалите себя из списка участников, либо удалите весь чат. +Если позже вы снова захотите присоединиться к группе, попросите другого участника группы добавить вас.

    +
  • +
  • +

    Или, вместо этого, вы можете “отключить уведомления” для группы — это означает, что вы будете получать все сообщения и сможете их писать, но больше не будете получать уведомления о новых сообщениях.

    +
  • +
+ +

+ + + Клонирование группы + + +

+ +

Вы можете дублировать группу, чтобы начать отдельное обсуждение +или исключить участников, незаметно для них.

+ +
    +
  • +

    Откройте профиль группы и нажмите Клонировать чат (Android/iOS), +или щелкните правой кнопкой мыши по группе в списке чатов (приложение для ПК).

    +
  • +
  • +

    Установите новое имя, выберите аватар и при необходимости отредактируйте список участников.

    +
  • +
+ +

Новая группа полностью независима от исходной, +которая продолжает работать как прежде.

+ +

+ + + Встроенные приложения чата + + +

+ +

Вы можете отправлять приложения в чат - игры, редакторы, опросы и другие инструменты. +Это делает Delta Chat по-настоящему расширяемым мессенджером.

+ +

+ + + Где можно найти встроенные приложения? + + +

+ + + +

+ + + Насколько конфиденциальны приложения внутри чата? + + +

+ +
    +
  • +

    Встроенные приложения не могут отправлять данные в Интернет или что-либо загружать.

    +
  • +
  • +

    Встроенное приложение может обмениваться данными только внутри чата Delta Chat с его +копиями на устройствах ваших собеседников. В остальном оно полностью +изолировано от Интернета.

    +
  • +
  • +

    Конфиденциальность встроенного приложения соответствует конфиденциальности вашего чата +— пока вы доверяете людям, с которыми общаетесь, можете доверять и встроенному приложению.

    +
  • +
  • +

    Это также означает: как и в случае с веб-ссылками, не открывайте приложения от ненадежных контактов.

    +
  • +
+ +

+ + + Как создать собственные приложения внутри чата? + + +

+ +
    +
  • +

    Встроенные приложения - это ZIP-файлы с расширением .xdc, содержащие HTML-, CSS- и JavaScript-код.

    +
  • +
  • +

    Вы можете расширить пример приложения Hello World +чтобы начать работу.

    +
  • +
  • +

    Всю остальную информацию вы найдёте в +документации Webxdc.

    +
  • +
  • +

    Если у вас есть вопросы, вы можете обратиться к другим пользователям с опытом +на форуме Delta Chat.

    +
  • +
+ +

+ + + Мгновенная доставка сообщений и Push-уведомления + + +

+ +

+ + + Что такое Push-уведомления? Как я могу получить мгновенную доставку сообщений? + + +

+ +

Push-уведомления отправляются “Push-сервисами” Apple и Google на устройство пользователя, чтобы неактивное приложение Delta Chat могло получать сообщения в фоновом режиме +и при необходимости показывать уведомления на телефоне пользователя.

+ +

Push-уведомления работают со всеми серверами chatmail на

+ +
    +
  • +

    iOS устройствах, путем интеграции с сервисами Apple Push.

    +
  • +
  • +

    Android устройствах, путем интеграции с сервисом Google FCM Push, +в том числе на устройствах, использующих microG +вместо проприетарного кода Google на телефоне.

    +
  • +
+ +

+ + + Включены ли Push-уведомления на устройствах iOS? Есть ли альтернатива? + + +

+ +

Да, Delta Chat автоматически использует Push-уведомления для профилей chatmail. +И нет, на устройствах Apple нет альтернативы для обеспечения мгновенной доставки сообщений, +поскольку устройства Apple не позволяют Delta Chat запрашивать данные в фоновом режиме. +Push-уведомления автоматически активируются для пользователей iOS, потому что +Система Push-уведомлений Delta Chat, обеспечивающая конфиденциальность +не передает данные Apple, которых у нее еще нет.

+ +

+ + + Включены / нужны ли Push-уведомления на устройствах Android? + + +

+ +

Если доступен “push-сервис” Delta Chat включает push-уведомления, +чтобы обеспечить мгновенную доставку сообщений для всех пользователей chatmail.

+ +

В настройках Delta Chat “Уведомления”, раздел “Мгновенная доставка” +вы можете изменить следующие настройки, которые влияют на все профили чата:

+ +
    +
  • +

    “Использовать соединение в фоновом режиме”: если вы не используете сервис Push, +вы можете отключить “оптимизацию батареи” для Delta Chat, +позволяя ему получать сообщения в фоновом режиме. +Однако могут быть задержки от нескольких минут до нескольких часов. +Некоторые производители Android даже полностью ограничивают приложения +(см. dontkillmyapp.com) +и Delta Chat может не показывать входящие сообщения, +пока вы снова не откроете приложение вручную.

    +
  • +
  • +

    Принудительное соединение в фоновом режиме: это запасной вариант +если предыдущие варианты недоступны или не обеспечивают “мгновенную доставку”. +Включение этого параметра вызывает постоянное уведомление на вашем телефоне, +которое иногда может быть “сжато” на последних телефонах Android.

    +
  • +
+ +

Оба варианта “Фонового соединения” являются энергосберегающими и +безопасными для использования, если вы заметили, что сообщения приходят только с большой задержкой.

+ +

+ + + Насколько конфиденциальны Push-уведомления Delta Chat? + + +

+ +

Поддержка push-уведомлений в Delta Chat предотвращает утечку личной информации. +Она не передаёт данные профиля, IP-адрес или содержимое сообщений (даже зашифрованных) +ни одной системе, участвующей в доставке уведомлений.

+ +

Вот как приложения Delta Chat выполняют доставку Push-уведомлений:

+ +
    +
  • +

    Приложение Delta Chat локально получает “токен устройства” шифрует его и сохраняет +на сервере chatmail.

    +
  • +
  • +

    Когда сервер chatmail получает сообщение для пользователя Delta Chat, +он пересылает зашифрованный токен устройства центральному прокси-серверу уведомлений Delta Chat.

    +
  • +
  • +

    Центральный прокси-сервер уведомлений Delta Chat расшифровывает токен устройства +и пересылает его соответствующему сервису push-уведомлений (Apple, Google и т.д.), +не зная IP-адреса или данных профиля пользователей Delta Chat.

    +
  • +
  • +

    Центральный сервис push-уведомлений (Apple, Google и т.д.) +пробуждает приложение Delta Chat на вашем устройстве +для проверки новых сообщений в фоновом режиме. +Он не знает о данных профиля устройства, которое он пробуждает. +Центральные сервисы Apple/Google Push никогда не видят никаких данных профиля (отправителя или получателя), +а также не видят содержимого сообщений (в том числе в зашифрованном виде).

    +
  • +
+ +

Центральный прокси-сервер уведомлений Delta Chat небольшой и полностью реализован на Rust +забывает о токенах устройств, как только Apple/Google/и т. д. обработали их, +обычно за несколько миллисекунд.

+ +

Обратите внимание, что токен устройства шифруется при передаче между приложениями и прокси-сервером уведомлений +но не подписывается. +Таким образом, прокси-сервер уведомлений никогда не видит данные профиля, IP-адреса или +какую-либо криптографическую идентификационную информацию, связанную с устройством (токеном) пользователя.

+ +

В результате такого общего подхода к обеспечению конфиденциальности, даже захват почтового сервера chatmail, +или полный захват центрального прокси-сервера уведомлений Delta Chat +не раскроет конфиденциальную информацию, которой сервисы Push уже не обладают.

+ +

+ + + Почему Delta Chat интегрируется с централизованными проприетарными Push-сервисами Apple/Google? + + +

+ +

Delta Chat — это бесплатный децентрализованный мессенджер с открытым исходным кодом и возможностью выбора сервера, +но мы хотим, чтобы пользователи гарантированно получали “мгновенную доставку” сообщений, +такую же, что и в приложениях WhatsApp, Signal или Telegram, +не задавая вопросов, которые больше подходят для опытных пользователей или разработчиков.

+ +

Обратите внимание, что в Delta Chat используется компактная и приватная система push-уведомлений +которая обеспечивает “мгновенную доставку” сообщений для всех серверов chatmail +включая тот, который вы можете настроить сами без нашего разрешения. +Добро пожаловать в мощную интероперабельную сеть релеев chatmail :)

+ +

+ + + Мультиклиент + + +

+ +

+ + + Можно ли использовать Delta Chat на нескольких устройствах одновременно? + + +

+ +

Да. Вы можете использовать один и тот же профиль на разных устройствах:

+ +
    +
  • +

    Убедитесь, что оба устройства подключены к одному и тому же Wi-Fi или локальной сети

    +
  • +
  • +

    На первом устройстве перейдите в Настройки → Добавить второе устройство, при необходимости разблокируйте экран +и подождите немного, пока не появится QR-код

    +
  • +
  • +

    На втором устройстве, установите Delta Chat

    +
  • +
  • +

    На втором устройстве запустите Delta Chat, выберите Добавить как второе устройство и отсканируйте QR-код со старого устройства

    +
  • +
  • +

    Передача должна начаться через несколько секунд и во время передачи на обоих устройствах будет показан прогресс. +Подождите, пока передача не завершится на обоих устройствах.

    +
  • +
+ +

В отличие от многих других мессенджеров, после успешной передачи, +оба устройства полностью независимы. +Одно устройство не требуется для работы другого.

+ +

+ + + Устранение неполадок + + +

+ +
    +
  • +

    Перепроверьте, что оба устройства находятся в одной Wi-Fi или локальной сети.

    +
  • +
  • +

    В Windows перейдите в Панель управления / Сеть и Интернет +и убедитесь, что в качестве “Типа сетевого профиля” выбрана Частная сеть. +(после передачи, вы можете изменить обратно на исходное значение)

    +
  • +
  • +

    На iOS, убедитесь, что предоставлен доступ “Настройки системы / Приложения / Delta Chat / Локальная сеть

    +
  • +
  • +

    На macOS, включите “Системные настройки / Конфиденциальность и безопасность / Локальная сеть / Delta Chat”

    +
  • +
  • +

    В вашей системе может быть установлен “персональный брандмауэр”, +который может вызвать проблемы (особенно на Windows). +Отключите персональный брандмауэр для Delta Chat на обоих сторонах и попробуйте снова

    +
  • +
  • +

    Гостевые сети могут блокировать взаимодействие между устройствами. +Если возможно, используйте не гостевую сеть.

    +
  • +
  • +

    Если у вас всё ещё возникают проблемы с использованием одной и той же сети, +попробуйте создать Мобильную точку доступа на одном устройстве и подключиться к этой сети Wi-Fi с другого устройства

    +
  • +
  • +

    Убедитесь, что на устройстве-приемнике имеется достаточно места

    +
  • +
  • +

    Если передача началась, убедитесь, что устройства остаются активными и не переходят в режим сна. +Не выходите из Delta Chat. +(мы стараемся сделать так, чтобы приложение работало в фоновом режиме, но системы склонны убивать приложения, к сожалению)

    +
  • +
  • +

    Delta Chat уже авторизован на устройстве-приемнике? +Вы можете использовать несколько профилей на одном устройстве, просто добавьте еще один профиль

    +
  • +
  • +

    Если у вас всё ещё возникают проблемы или вы не можете отсканировать QR-код +попробуйте ручную передачу, описанную ниже

    +
  • +
+ +

+ + + Ручная передача + + +

+ +

Этот метод рекомендуется использовать только в том случае, если функция “Добавить второе устройство”, описанная выше, не работает.

+ +
    +
  • На старом устройстве, перейдите в “Настройки -> Чаты и медиафайлы -> Экспорт резервной копии”. Введите свой +PIN-код разблокировки экрана, графический ключ или пароль. Затем вы можете нажать “Начать +резервное копирование”. Это сохранит файл резервной копии на вашем устройстве. Теперь вам нужно передать +его на другое устройство каким-то образом.
  • +
  • На новом устройстве, в меню “У меня уже есть профиль”, +выберите “Восстановить из резервной копии”. После импорта, ваши чаты, ключи +шифрования, медиафайлы будут скопированы на новое устройство. +
      +
    • Если вы используете iOS: и у вас возникли трудности, возможно, +это руководство +поможет вам.
    • +
    +
  • +
  • Теперь вы синхронизированы и можете использовать оба устройства для отправки и получения + зашифрованных сквозным шифрованием сообщений с вашими собеседниками.
  • +
+ +

+ + + Есть ли какие-либо планы по внедрению веб-клиента Delta Chat? + + +

+ +
    +
  • Ближайших планов нет, но есть некоторые предварительные идеи.
  • +
  • Существуют 2-3 способа внедрения веб-клиента Delta Chat, но все +они требуют значительных усилий. На данный момент мы сосредоточены на выпуске стабильных релизов во всех +магазинах приложений (Google Play/iOS/Windows/macOS/Linux).
  • +
  • Если вам нужен веб-клиент, потому что вы не можете устанавливать программное обеспечение на +компьютере, на которым работаете, вы можете использовать портативный клиент для настольных систем Windows +или AppImage для Linux. Вы можете найти их на +get.delta.chat.
  • +
+ +

+ + + Расширенные + + +

+ +

+ + + Экспериментальные функции + + +

+ +

В разделе Настройки → Дополнительно → Экспериментальные функции +вы можете опробовать функции, над которыми мы работаем.

+ +

Эти функции могут быть нестабильными и могут быть изменены или удалены.

+ +

Вы можете найти дополнительную информацию +и оставить отзыв на Форуме.

+ +

+ + + Что означает “Отправлять статистику разработчикам Delta Chat”? + + +

+ +

Мы хотели бы улучшить Delta Chat с вашей помощью, +поэтому Delta Chat для Android спрашивает, хотите ли вы +отправлять анонимную статистику использования.

+ +

Вы можете включить или отключить эту функцию +в разделе Настройки → Дополнительно → Отправить статистику разработчикам Delta Chat.

+ +

При включении +еженедельная статистика будет автоматически отправляться боту.

+ +

Нас интересует, например, следующая статистика:

+ +
    +
  • Сколько контактов добавляется путём личного сканирования QR-кода?
  • +
  • Какие версии Delta Chat используются?
  • +
  • Сколько сообщений в незашифрованном виде?
  • +
+ +

Мы не собираем какую-либо информацию, позволяющую идентифицировать вас лично.

+ +

+ + + Могу ли я использовать обычный адрес электронной почты с Delta Chat? + + +

+ +

Да, но только если адрес электронной почты используется исключительно chatmail клиентами.

+ +

Не поддерживается совместное использование адреса электронной почты с приложениями, не являющимися клиентами chatmail или веб-интерфейсами для работы с почтой, +по следующим причинам:

+ +
    +
  • +

    Приложения, не являющиеся клиентами chatmail, в основном не обеспечивают автоматическое сквозное шифрование электронной почты для своих пользователей, +тогда как приложения и релеи chatmail повсеместно применяют сквозное шифрование и стандарты безопасности.

    +
  • +
  • +

    Приложения, не являющиеся клиентами chatmail, используют серверы электронной почты в качестве долгосрочного архива сообщений, +тогда как клиенты chatmail используют почтовые серверы для передачи мгновенных сообщений с коротким сроком жизни.

    +
  • +
  • +

    Поддержка всего разнообразия классических настроек электронной почты +потребует значительных усилий по разработке и сопровождению, +а также усложнит повышение устойчивости, надёжности и скорости обмена сообщениями на основе chatmail.

    +
  • +
+ +

+ + + Как настроить профиль чата с использованием классического адреса электронной почты в качестве транспорта? + + +

+ +

Прежде всего, не используйте те же классические адреса электронной почты, которые используются в обычных приложениях для отправки писем +если вы не готовы к зашифрованным сообщениям во входящих, +двойным уведомлениям, случайному удалению писем или подобным неудобствам.

+ +

Вы можете настроить адрес электронной почты для чата в разделе Новый профиль → Использовать другой сервер → Использовать классическую почту в качестве транспорта. +Обратите внимание, что классические почтовые провайдеры обычно не поддерживают Push-уведомления +и имеют другие ограничения, см. раздел Обзор провайдеров. +Chatmail использует INBOX по умолчанию для ретрансляции; убедитесь, что провайдер также настроен. +Профиль чата, использующий классический адрес электронной почты, позволяет отправлять и получать незашифрованные сообщения. +Эти сообщения и чаты, в которых они появляются, помечаются значком электронной почты +email.

+ +

+ + + Я хочу управлять своим собственным сервером для Delta Chat. Что вы посоветуете? + + +

+ +

Подойдет любая корректная настройка почтового сервера, за исключением случаев, когда для корректной работы устройств ваших пользователей требуются Push-уведомления Google/Apple.

+ +

Мы обычно рекомендуем настроить chatmail релей. +Chatmail — это проект, поддерживаемый сообществом который охватывает как настройку релеев, так +и основные разработки на Rust +которые обеспечивают работу клиентов chatmail наиболее известным из которых является Delta Chat.

+ +

+ + + Меня интересуют технические детали. Можете рассказать больше? + + +

+ + + +

+ + + Шифрование и безопасность + + +

+ +

+ + + Какие стандарты используются для сквозного шифрования? + + +

+ +

Delta Chat использует безопасное подмножество стандарта OpenPGP +для реализации автоматического сквозного шифрования c помощью следующих протоколов:

+ +
    +
  • +

    Secure-Join +для обмена информацией о настройке шифрования через сканирование QR-кода или “ссылок-приглашений”.

    +
  • +
  • +

    Autocrypt используется для автоматической +настройки сквозного шифрования между контактами и всеми членами группового чата.

    +
  • +
  • +

    Обмен контактом в +чате +позволяет получателям настроить сквозное шифрование с этим контактом.

    +
  • +
+ +

Delta Chat не запрашивает, не публикует и не взаимодействует с какими-либо серверами ключей OpenPGP.

+ +

+ + + Как узнать, зашифрованы ли сообщения с помощью сквозного шифрования? + + +

+ +

Все сообщения в Delta Chat по умолчанию шифруются сквозным шифрованием. +С момента выхода серии релизов Delta Chat версии 2 (июль 2025 года) +на сообщениях со сквозным шифрованием больше не отображаются замки или другие подобные индикаторы.

+ +

+ + + Могу ли я получать или отправлять сообщения без сквозного шифрования? + + +

+ +

Если вы используете стандартные релеи chatmail, +невозможно получать или отправлять сообщения без сквозного шифрования.

+ +

Если вы вместо этого используете классический почтовый сервер, +вы можете отправлять и получать сообщения с использованием или без сквозного шифрования. +Сообщения, не имеющие сквозного шифрования, отмечены значком электронной почты +email.

+ +

+ + + Что означает зеленая галочка в профиле контакта? + + +

+ +

В профиле контакта может отображаться зелёная галочка +зелёная галочка +и строка “Подтверждён пользователем”. +Каждый контакт с зелёной галочкой либо выполнил непосредственное сканирование QR-кода с вами, +либо был подтверждён другим контактом с зелёной галочкой. +Подтверждения происходят автоматически при добавлении участников в группы. +Тот, кто добавляет контакт с зелёной галочкой в группу, где только участники с зелёными галочками, +становится подтверждающим. +В профиле контакта вы можете последовательно нажимать на текст “Подтверждён …” до тех пор, +пока не дойдёте до того, с кем вы напрямую выполнили сканирование QR-кода.

+ +

Для более подробного обсуждения “гарантированного сквозного шифрования” +пожалуйста, обратитесь к протоколам Secure-Join +и прочитайте о “Проверенных группах”, техническом термине, +который здесь называется “группами с зелёной галочкой” или чатами с “гарантированным сквозным шифрованием”.

+ +

+ + + Зашифрованы ли вложения (изображения, файлы, аудио и т. д.) сквозным шифрованием? + + +

+ +

Да.

+ +

Когда мы говорим о “сообщении, зашифрованном сквозным шифрованием” +мы всегда имеем в виду, что всё сообщение зашифровано, +включая все вложения +и метаданные вложений, такие как имена файлов.

+ +

+ + + OpenPGP безопасен? + + +

+ +

Да, Delta Chat использует безопасное подмножество OpenPGP +требующее, чтобы всё сообщение было правильно зашифровано и подписано. +Например, “Отделённые подписи” не считаются безопасными.

+ +

OpenPGP сам по себе не является небезопасным. +Большинство обсуждаемых в публичной сфере проблем безопасности OpenPGP +на самом деле возникают из-за плохого удобства использования или плохой реализации инструментов или приложений (или того и другого). +Особенно важно различать OpenPGP, стандарт шифрования IETF, +и GnuPG (GPG), инструмент командной строки, реализующий OpenPGP. +Многие публичные критические замечания по OpenPGP фактически касаются GnuPG, который Delta Chat никогда не использовал. +Delta Chat вместо этого использует реализацию OpenPGP на Rust rPGP, +доступную как независимый пакет “pgp”, +и проверенную на безопасность в 2019 и 2024 годах.

+ +

Вместе с другими разработчиками OpenPGP мы стремимся, +повысить характеристики безопасности путём реализации +нового стандарта IETF OpenPGP Crypto-Refresh, +который был успешно принят летом 2023 года.

+ +

+ + + Рассматривали ли вы альтернативы OpenPGP для сквозного шифрования? + + +

+ +

Да, мы следим за разработками, такими как MLS +но их внедрение означало бы потерю совместимости сквозного шифрования. +Так что это будет непростое решение +и оно должно принести пользователям ощутимые улучшения.

+ +

Delta Chat использует комплексный подход “удобной безопасности” +и работает с широким кругом групп активистов, а также +известными исследователями, такими как TeamUSEC, +для улучшения фактических результатов пользователей в борьбе с угрозами безопасности. +Протокол передачи данных и стандарт для установления сквозного шифрования: +только одна часть “результатов пользователя”, +см. также наши ответы на захват устройстваметаданные сообщения.

+ +

+ + + Подвержен ли Delta Chat уязвимости EFAIL? + + +

+ +

Нет, Delta Chat никогда не был уязвим к EFAIL +потому что его реализация OpenPGP rPGP +использует код обнаружения модификации при шифровании сообщений +и возвращает ошибку +если код обнаружения модификаций не совпадает.

+ +

Delta Chat также никогда не был уязвим к атаке “Direct Exfiltration” EFAIL, +потому что он расшифровывает только многочастные/зашифрованные сообщения, +которые содержат ровно одну зашифрованную и подписанную часть, +как определено спецификацией Autocrypt Level 1.

+ +

+ + + Видны ли в Интернете сообщения, отмеченные значком почты? + + +

+ +

Если вы отправляете или получаете электронные сообщения без сквозного шифрования (используя классический почтовый сервер), +они всё равно защищены от операторов сотовой связи или интернет-провайдеров, которые не могут прочитать или изменить ваши электронные сообщения. +Однако и ваш почтовый провайдер, и почтовый провайдер вашего получателя +могут читать, анализировать или изменять ваши сообщения, включая любые вложения.

+ +

Delta Chat по умолчанию использует строгое +TLS-шифрование, +которое защищает соединения между вашим устройством и провайдером электронной почты. +Вся обработка TLS в Delta Chat прошла независимый аудит безопасности. +Более того, соединение между вашим устройством и провайдером электронной почты получателя +как правило, также будет зашифровано. +Если задействованные серверы электронной почты поддерживают MTA-STS, +то между провайдерами электронной почты будет применяться протокол защиты транспортного уровня. +В этом случае сообщения Delta Chat никогда не будут переданы в открытом виде через Интернет, +даже если сообщение не было зашифровано сквозным шифрованием.

+ +

+ + + Как Delta Chat защищает метаданные в сообщениях? + + +

+ +

В отличие от большинства других мессенджеров, +приложения Delta Chat не сохраняют никакие метаданные о контактах или группах на серверах, даже в зашифрованной форме. +Вместо этого все групповые метаданные шифруются сквозным шифрованием и хранятся исключительно на устройствах конечных пользователей.

+ +

Таким образом, серверы могут видеть только:

+ +
    +
  • адрес отправителя и получателя
  • +
  • а также размер сообщения.
  • +
+ +

По умолчанию адреса генерируются случайным образом.

+ +

Все прочие метаданные сообщений, контактов и групп содержатся в части сообщений, защищённой сквозным шифрованием.

+ +

+ + + Как защитить метаданные и контакты при изъятии устройства? + + +

+ +

Для защиты от серверов электронной почты, собирающих метаданные, +а также от угрозы конфискации устройства, +мы рекомендуем использовать релей chatmail +для создания чат-профилей с использованием случайных электронных адресов для передачи сообщений. +Обратите внимание, что приложения Delta Chat на всех платформах поддерживают несколько профилей, +так что вы можете легко использовать отдельные профили, для конкретной ситуации, помимо вашего “основного” профиля, +зная, что все их данные, вместе с метаданными, будут удалены. +Кроме того, если устройство изъято, контакты, использующие временные профили, +не могут быть легко идентифицированы.

+ +

+ + + Поддерживает ли Delta Chat функцию “Sealed Sender” (Засекреченный отправитель)? + + +

+ +

Нет, пока нет.

+ +

Мессенджер Signal внедрил функцию “Sealed Sender” (Засекреченный отправитель) в 2018 году, +чтобы их серверная инфраструктура не имела информации о том, кто отправляет сообщение группе получателей. +Это особенно важно, поскольку сервер Signal знает мобильный номер каждого аккаунта, +который обычно привязан к паспортным данным.

+ +

Даже если релеи chatmail +не запрашивают никаких личных данных (включая телефонные номера), +всё равно может быть полезно защитить реляционные метаданные между адресами +Мы не видим серьёзных проблем в использовании случайных одноразовых электронных адресов для функции sealed sender, +но реализация пока не определена как приоритетная задача.

+ +

+ + + Поддерживает ли Delta Chat свойство Perfect forward secrecy, PFS (Совершенную прямую секретность)? + + +

+ +

Нет, пока нет.

+ +

На данный момент, Delta Chat не поддерживает Perfect Forward Secrecy (PFS) (Совершенную прямую секретность). +Это означает, что если ваш приватный ключ дешифрования будет скомпрометирован, +и кто-то собрал ваши предыдущие сообщения во время передачи, +они смогут расшифровать и прочитать их, используя скомпрометированный ключ дешифрования. +Обратите внимание, что Forward Secrecy (Прямая секретность) повышает безопасность только если вы удаляете сообщения. +В противном случае, тот, кто получает ваши ключи дешифрования +также может получить все ваши не удалённые сообщения +и ему даже не нужно расшифровывать какие-либо ранее собранные сообщения.

+ +

Мы разработали подход к Forward Secrecy (Прямой секретности), который прошёл +первичную проверку некоторыми криптографами и экспертами по реализации +но требует более формального описания +чтобы убедиться, что он надёжно работает в федеративном обмене сообщениями и при использовании нескольких устройств, +прежде чем он может быть внедрён в ядро chatmail, +что сделает его доступным во всех клиентах clients.

+ +

+ + + Поддерживает ли Delta Chat Post-Quantum-Cryptography (Постквантовую криптографию)? + + +

+ +

Нет, пока нет.

+ +

Delta Chat использует библиотеку OpenPGP на Rust rPGP, +которая поддерживает последний черновик IETF Post-Quantum-Cryptography OpenPGP. +Мы планируем добавить поддержку PQC в ядро chatmail после того, как черновик будет окончательно утвержден в IETF +в сотрудничестве с другими разработчиками OpenPGP.

+ +

+ + + Как можно вручную проверить информацию о шифровании? + + +

+ +

Вы можете проверить статус сквозного шифрования вручную в диалоговом окне “Шифрование” +(профиль пользователя на Android/iOS или щелкните правой кнопкой мыши элемент списка чата пользователя в приложении для ПК). +Delta Chat показывает там два отпечатка. +Если на вашем устройстве и на устройстве вашего контакта показаны одинаковые отпечатки, +соединение безопасно.

+ +

+ + + Можно ли повторно использовать существующий секретный ключ? + + +

+ +

Нет.

+ +

Delta Chat генерирует безопасные ключи OpenPGP в соответствии со спецификацией Autocrypt 1.1. +Мы не рекомендуем и не предлагаем пользователям управлять ключами вручную. +Мы хотим, чтобы аудиты безопасности могли сосредоточиться на нескольких проверенных криптографических алгоритмах +а не на всем многообразии возможных алгоритмов, разрешенных в OpenPGP. +Если вы хотите извлечь свой ключ OpenPGP, существует только экспертный метод: +вам нужно найти его в SQLite-таблице “keypairs” в tar-файле резервной копии профиля.

+ +

+ + + Проходил ли Delta Chat независимую проверку на наличие уязвимостей безопасности? + + +

+ +

Да, множество раз. +Проект Delta Chat находится в постоянном режиме аудита безопасности и анализа, +от последних до более ранних:

+ +
    +
  • +

    Декабрь 2024 года, экспертиза rPGP, организованная + NLNET выполненная Radically Open Security. +rPGP является движком сквозного шифрования OpenPGP OpenPGP в Delta Chat. +В результате этого аудита были подготовлены два отчета по безопасности:

    + + + +

    Проблемы, описанные в этих рекомендациях, были исправлены и включены в релизы Delta Chat, +во всех магазинах приложений с декабря 2024 года.

    +
  • +
  • +

    В марте 2024 года мы получили подробный анализ безопасности от исследовательской группы +Applied Cryptography в ETH Цюрихе и устранили все выявленные проблемы. +Читайте наш пост в блоге о Hardening Guaranteed End-to-End encryption для более подробной информации и +Cryptographic Analysis of Delta Chat +научной статьи, опубликованной позже.

    +
  • +
  • +

    В Апреле 2023 года мы исправили проблемы безопасности и конфиденциальности в веб-интерфейсе, +функция “приложения, которыми делятся в чате”, связанная со сбоями в песочнице +особенно с Chromium. После этого мы провели независимый аудит +безопасности с Cure53, и все выявленные проблемы были исправлены в серии приложений 1.36, выпущенной в апреле 2023 года. +См. здесь полную информацию о безопасности сквозного шифрования в Интернете.

    +
  • +
  • +

    В Марте 2023 года, Cure53 проанализировал как протокол защиты транспортного уровня +сетевого соединения Delta Chat, так и воспроизводимую установку почтового сервера, +рекомендуемую на этом сайте. +Подробнее об аудите можно узнать в нашем блоге +или прочитать полный отчёт здесь.

    +
  • +
  • +

    2020 год, Include Security проанализировала Delta +Chat Rust ядро, +IMAP, +SMTP и +Библиотеки TLS. +Никаких критических или серьёзных проблем обнаружено не было. +В отчете выявлено несколько слабых мест средней серьёзности: +они не представляют угрозы для пользователей Delta Chat сами по себе, +поскольку они зависят от окружения, в котором используется Delta Chat. +По соображениям удобства использования и совместимости, +мы не можем устранить их все +и решили предоставить рекомендации по безопасности для пользователей, которым угрожают эти уязвимости. +Вы можете прочитать полный отчет здесь.

    +
  • +
  • +

    2019 год Include Security проанализировало библиотеки Delta +Chat PGP и +RSA. +Не было обнаружено критических проблем, +но были выявлены две серьёзные проблемы, которые мы впоследствии исправили. +Также были выявлены, одна проблема средней серьёзности и несколько менее опасных проблем, +но не было возможности эксплуатировать эти уязвимости в реализации Delta Chat. +Некоторые из них мы исправили после завершения аудита. +Вы можете прочитать полный отчёт здесь.

    +
  • +
+ +

+ + + Разное + + +

+ +

+ + + Какие разрешения нужны Delta Chat? + + +

+ +

Некоторые функции требуют определенных разрешений, +например, вам нужно предоставить разрешение на использование камеры, если вы хотите отсканировать QR-код приглашения.

+ +

Подробную информацию можно найти в Политике конфиденциальности.

+ +

+ + + Где мои друзья могут найти Delta Chat? + + +

+ +

Delta Chat доступен на всех популярных и некоторых менее известных платформах:

+ +
    +
  • +

    На официальной странице загрузки, https://delta.chat/download можно найти подробную информацию о всех вариантах

    +
  • +
  • +

    Если основной сайт недоступен, используйте зеркало https://deltachat.github.io/deltachat-pages

    +
  • +
  • +

    Откройте один из следующих магазинов приложений и найдите “Delta Chat”: +Google Play Store, F-Droid, Huawei App Gallery, iOS и macOS App Store, Microsoft Store

    +
  • +
  • +

    Проверьте менеджер пакетов вашего дистрибутива Linux

    +
  • +
  • +

    Файлы APK для Android доступны на https://github.com/deltachat/deltachat-android/releases

    +
  • +
+ +

+ + + Как финансируются разработки Delta Chat? + + +

+ +

Delta Chat не получает никакого венчурного капитала, +не имеет долгов, и не ставит целью заработать огромную прибыль или +продавать пользователей, их друзей и семью рекламодателям (или ещё кому-либо). +Мы предпочитаем использовать государственные источники финансирования далёкие от Евросоюза и США, чтобы +продолжить создавать децентрализованную экосистему обмена сообщениями, +основанную на Свободном и Открытом исходном коде.

+ +

В частности, разработка Delta Chat финансировалась из следующих источников, +перечислены в хронологическом порядке:

+ +
    +
  • +

    Проект ЕС NEXTLEAP финансировал исследование +и внедрение проверенных групп и настройку протоколов контактов +в 2017 и 2018 годах, а также помог интегрировать сквозное шифрование +через Autocrypt.

    +
  • +
  • +

    Фонд Open Technology Fund предоставил нам +первый грант в 2018/2019 году (~$200 тыс.), благодаря которому мы существенно улучшили приложение для Android +и выпустили первую бета-версию приложения для настольных систем, а также провели +исследования в области UX в контексте прав человека, + см. наш заключительный отчет Needfinding and UX report. +Второй грант, полученный в 2019/2020 году (~$300 тыс.), помог нам +выпустить версии Delta/iOS, перевести наш основной код на Rust и +предоставить новые функции для всех платформ.

    +
  • +
  • +

    Фонд NLnet выделил в 2019/2020 году 46 тыс. евро на +завершение привязки Rust/Python и создание экосистемы чат-ботов.

    +
  • +
  • +

    В 2021 г. мы получили дополнительное финансирование из ЕС для двух Next-Generation-Internet +целей, а именно для EPPD - e-mail provider portability directory (~97 тыс. евро) и AEAP - email address porting (~90 тыс. евро). Это привело к улучшению поддержки нескольких профилей, улучшению настройки контактов и групп с помощью QR-кода и многим улучшениям в сетевом взаимодействии на всех платформах.

    +
  • +
  • +

    С конца 2021 года по март 2023 года мы получили финансирование в размере ($500 тыс.) от +U.S. Bureau of Democracy, Human Rights and Labor (DRL) для поддержки свободы интернета. +Это финансирование поддержало наши долгосрочные цели, сделать Delta Chat более удобным для использования +и совместимым с широким спектром электронных почтовых серверов по всему миру, а также более устойчивым +и безопасным в местах, часто подвергающихся интернет-цензуре и отключениям.

    +
  • +
  • +

    2023-2024 мы завершили проект финансируемый OTF +Secure Chatmail project, +что позволило нам внедрить гарантированное шифрование, +создать сеть серверов chatmail +и обеспечить “немедленную регистрацию” во всех приложениях, выпущенных с апреля 2024 года.

    +
  • +
  • +

    В 2023 и 2024 годах мы были приняты в программу Next Generation Internet (NGI) +за нашу работу над webxdc PUSH, +в сотрудничестве с партнерами, работающими над +webxdc evolve, +webxdc XMPP, +DeltaTouch и +DeltaTauri. +Все эти проекты частично завершены или будут завершены в начале 2025 года.

    +
  • +
  • +

    Иногда мы получаем разовые пожертвования от физических лиц. +Например, в 2021 году один щедрый человек перевел нам 4 тыс. евро банковским переводом +с подписью “Продолжайте хорошие разработки!”. 💜 +Мы используем такие деньги для финансирования собраний разработчиков или для покрытия непредвиденных расходов, +которые не могут быть легко предсказаны или возмещены из грантов общественного финансирования. +Получение большего количества пожертвований также помогает нам стать более независимыми и жизнеспособными в долгосрочной перспективе +как сообщество участников.

    + + +
  • +
  • +

    И последнее, но далеко не менее важное: несколько экспертов и энтузиастов безвозмездно внесли +и вносят свой вклад в развитие Delta Chat, не получая за это денег или получая +только небольшие суммы. Без них Delta Chat не был бы там, где он находится сегодня, +даже близко.

    +
  • +
+ +

Финансирование, упомянутое выше, в основном организовано компанией merlinux GmbH в +Фрайбурге (Германия) и распределяется среди более чем десятка участников по всему миру.

+ +

Пожалуйста, обратите внимание на Каналы пожертвований в Delta Chat +для финансовых взносов и других возможностей участия.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/saved-icon.png b/src/main/assets/help/saved-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..27c2812ef2f39c3138c6f46ad561850d7cd1a977 Binary files /dev/null and b/src/main/assets/help/saved-icon.png differ diff --git a/src/main/assets/help/sk/help.html b/src/main/assets/help/sk/help.html new file mode 100644 index 0000000000000000000000000000000000000000..ef2afecae766f92b3801599b2984428a88f6e9b9 --- /dev/null +++ b/src/main/assets/help/sk/help.html @@ -0,0 +1,1668 @@ + + + + + +

+ + + Čo je to Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    You need to accept the request before you can reply.

    +
  • +
  • +

    You can also delete it if you don’t want to chat with them for now.

    +
  • +
  • +

    If you delete a request, future messages from that contact will still appear +as message request, so you can change your mind. If you really don’t want to +receive messages from this person, consider blocking them.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Podporuje Delta Chat obrázky, videá a iné prílohy? + + +

+ +
    +
  • +

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Kto vidí moju profilovú fotku? + + +

+ +
    +
  • +

    V nastaveniach si môžete pridať profilový obrázok. Ak napíšete svojim kontaktom +alebo si ich pridáte pomocou QR kódu, automaticky to vidia ako váš profilový obrázok.

    +
  • +
  • +

    Z dôvodu ochrany osobných údajov nikto nevidí váš profilový obrázok, kým im nenapíšete +správu.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + What do Pinning, Muting and Archiving mean? + + +

+ +

Use these tools to organize your chats and keep everything in its place:

+ +
    +
  • +

    Pinned chats always stay atop of the chat list. You can use them to access your most loved chats quickly or temporarily to not forget about things.

    +
  • +
  • +

    Mute chats if you do not want to get notifications for them. Muted chats stay in place and you can also pin a muted chat.

    +
  • +
  • +

    Archive chats if you do not want to see them in your chat list any longer. +Archived chats remain accessible above the chat list or via search.

    +
  • +
  • +

    When an archived chat gets a new message, unless muted, it will pop out of the archive and back into your chat list. +Muted chats stay archived until you unarchive them manually.

    +
  • +
+ +

To use the functions, long tap or right click a chat in the chat list.

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + What does the green dot mean? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Čo znamenajú zaškrtnutia zobrazené vedľa odchádzajúcich správ? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + What happens if I turn on “Delete old messages from device”? + + +

+ +
    +
  • If you want to save storage on your device, you can choose to delete old +messages automatically.
  • +
  • To turn it on, go to “delete old messages from device” in the “Chats & Media” +settings. You can set a timeframe between “after an hour” and “after a year”; +this way, all messages will be deleted from your device as soon as they are +older than that.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Vytvorenie skupiny + + +

+ +
    +
  • Vyberte Nový chat a potom Nová skupina z ponuky v pravom hornom rohu alebo stlačte príslušné tlačidlo v systéme Android/iOS.
  • +
  • Na nasledujúcej obrazovke vyberte členov skupiny a definujte názov skupiny. Môžete si tiež vybrať avatara skupiny.
  • +
  • Hneď ako napíšete prvú správu v skupine, všetci členovia sú informovaní o novej skupine a môžu odpovedať v skupine (pokiaľ nenapíšete správu v skupine, skupina je pre skupinu neviditeľná členovia).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Omylom som sa vymazal. + + +

+ +
    +
  • Keďže už nie ste členom skupiny, nemôžete sa znova pridať. +Žiadny problém, jednoducho požiadajte ktoréhokoľvek iného člena skupiny v bežnom chate, aby vás znova pridal.
  • +
+ +

+ + + Už viac nechcem dostávať správy od skupiny. + + +

+ +
    +
  • +

    Vymažte sa zo zoznamu členov alebo odstráňte celý chat. + Ak sa chcete neskôr znova pripojiť k skupine, požiadajte iného člena skupiny, aby vás znova pridal.

    +
  • +
  • +

    Ako alternatívu môžete tiež “Stlmiť” skupinu - znamená to, že budete dostávať všetky správy a +môžete stále písať, ale už nebudete upozorňovaní na žiadne nové správy.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multiklient + + +

+ +

+ + + Môžem používať Delta Chat na viacerých zariadeniach súčasne? + + +

+ +

Yes. You can use the same profile on different devices:

+ +
    +
  • +

    Make sure both devices are on the same Wi-Fi or network

    +
  • +
  • +

    On the first device, go to Settings → Add Second Device, unlock the screen if needed +and wait a moment until a QR code is shown

    +
  • +
  • +

    On the second device, install Delta Chat

    +
  • +
  • +

    On the second device, start Delta Chat, select Add as Second Device, and scan the QR code from the old device

    +
  • +
  • +

    Transfer should start after a few seconds and during transfer both devices will show the progress. +Wait until it is finished on both devices.

    +
  • +
+ +

In contrast to many other messengers, after successful transfer, +both devices are completely independent. +One device is not needed for the other to work.

+ +

+ + + Troubleshooting + + +

+ +
    +
  • +

    Double-check both devices are in the same Wi-Fi or network

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Your system might have a “personal firewall”, +which is known to cause problems (especially on Windows). +Disable the personal firewall for Delta Chat on both ends and try again

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Ensure there is enough storage on the destination device

    +
  • +
  • +

    If transfer started, make sure, the devices stay active and do not fall asleep. +Do not exit Delta Chat. +(we try hard to make the app work in background, but systems tend to kill apps, unfortunately)

    +
  • +
  • +

    Delta Chat is already logged in on the destination device? +You can use multiple profiles per device, just add another profile

    +
  • +
  • +

    If you still have problems or if you cannot scan a QR code +try the manual transfer described below

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

This method is only recommended if “Add Second Device” as described above does not work.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + Máte nejaké plány na zavedenie webového klienta Delta Chat? + + +

+ +

Neexistujú žiadne okamžité plány, ale niekoľko predbežných myšlienok.

+
    +
  • Existujú 2-3 spôsoby zavedenia webového klienta Delta Chat, ale všetky vyžadujú +významnú prácu. Zatiaľ sa zameriavame na to, aby sme dostali stabilné vydania do všetkých +obchodov s aplikáciami (repozitáre Google Play/iOS/Windows/macOS/Linux) ako natívne aplikácie.
  • +
  • Ak potrebujete webového klienta, pretože nemáte povolené inštalovať softvér +na počítač, s ktorým pracujete, môžete použiť prenosného klienta Windows Desktop Client, +alebo AppImage pre Linux. Nájdete ich na +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Zaujímajú ma technické detaily. Môžete mi povedať viac? + + +

+ + + +

+ + + Encryption and Security + + +

+ +

+ + + Which standards are used for end-to-end encryption? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Yes.

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + Is OpenPGP secure? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + Is Delta Chat vulnerable to EFAIL? + + +

+ +

No, Delta Chat never was vulnerable to EFAIL +because its OpenPGP implementation rPGP +uses Modification Detection Code when encrypting messages +and returns an error +if the Modification Detection Code is incorrect.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + How does Delta Chat protect metadata in messages? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + How to protect metadata and contacts when a device is seized? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + Môžem znova použiť svoj existujúci súkromný kľúč? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Was Delta Chat independently audited for security vulnerabilities? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    2023 April, we fixed security and privacy issues with the “web +apps shared in a chat” feature, related to failures of sandboxing +especially with Chromium. We subsequently got an independent security +audit from Cure53 and all issues found were fixed in the 1.36 app series released in April 2023. +See here for the full background story on end-to-end security in the web.

    +
  • +
  • +

    2023 March, Cure53 analyzed both the transport encryption of +Delta Chat’s network connections and a reproducible mail server setup as +recommended on this site. +You can read more about the audit on our blog +or read the full report here.

    +
  • +
  • +

    2020, Include Security analyzed Delta +Chat’s Rust core, +IMAP, +SMTP, and +TLS libraries. +It did not find any critical or high-severity issues. +The report raised a few medium-severity weaknesses - +they are no threat to Delta Chat users on their own +because they depend on the environment in which Delta Chat is used. +For usability and compatibility reasons, +we can not mitigate all of them +and decided to provide security recommendations to threatened users. +You can read the full report here.

    +
  • +
  • +

    2019, Include Security analyzed Delta +Chat’s PGP and +RSA libraries. +It found no critical issues, +but two high-severity issues that we subsequently fixed. +It also revealed one medium-severity and some less severe issues, +but there was no way to exploit these vulnerabilities in the Delta Chat implementation. +Some of them we nevertheless fixed since the audit was concluded. +You can read the full report here.

    +
  • +
+ +

+ + + Rôzne + + +

+ +

+ + + Aké povolenia potrebuje Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Ako sa financuje vývoj Delta Chat? + + +

+ +

Delta Chat does not receive any Venture Capital and +is not indebted, and under no pressure to produce huge profits, or to +sell users and their friends and family to advertisers (or worse). +We rather use public funding sources, so far from EU and US origins, to help +our efforts in instigating a decentralized and diverse chat messaging eco-system +based on Free and Open-Source community developments.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    The Open Technology Fund gave us a +first 2018/2019 grant (~$200K) during which we majorly improved the Android app +and released a first Desktop app beta version, and which moreover +moored our feature developments in UX research in human rights contexts, +see our concluding Needfinding and UX report. +The second 2019/2020 grant (~$300K) helped us to +release Delta/iOS versions, to convert our core library to Rust, and +to provide new features for all platforms.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Sometimes we receive one-time donations from private individuals. +For example, in 2021 a generous individual bank-wired us 4K EUR +with the subject “keep up the good developments!”. 💜 +We use such money to fund development gatherings or to care for ad-hoc expenses +that can not easily be predicted for, or reimbursed from, public funding grants. +Receiving more donations also helps us to become more independent and long-term viable +as a contributor community.

    + + +
  • +
  • +

    V neposlednom rade k vývoju Delta Chat prispelo a prispieva viacero pro-bono odborníkov a nadšencov + bez toho, aby dostávali peniaze alebo dostávali iba + malé množstvá. Bez nich by Delta Chat nebol tam, kde je dnes + ani zďaleka.

    +
  • +
+ +

The monetary funding mentioned above is mostly organized by merlinux GmbH in +Freiburg (Germany), and is distributed to more than a dozen contributors world-wide.

+ +

Please see Delta Chat Contribution channels +for both monetary and other contribution possibilities.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/sq/help.html b/src/main/assets/help/sq/help.html new file mode 100644 index 0000000000000000000000000000000000000000..370c28483068fc3d9404196cbd890b5f29c16f75 --- /dev/null +++ b/src/main/assets/help/sq/help.html @@ -0,0 +1,1672 @@ + + + + + +

+ + + Ç’është Delta Chat-i? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    Duhet të pranoni kërkesën, para se të mund të përgjigjeni.

    +
  • +
  • +

    Mundeni edhe ta fshini, nëse s’doni të bisedoni me të tani.

    +
  • +
  • +

    Nëse fshini një kërkesë, mesazhet e ardhshëm prej atij kontakti do të shfaqen +ende si kërkesë për mesazh, që të mund të ndërroni mendje. Nëse vërtet +s’doni të merrni mesazhe prej këtij personi, shihni mundësinë e bllokimit të tij.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + A mbulon Delta Chat-i figura, video dhe bashkëngjitje të tjera? + + +

+ +
    +
  • +

    Po Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    Si parazgjedhje, për funksionim më të mirë, figurat optimizohen dhe dërgohen në madhësi më të vogël, por mund ta dërgoni si një “kartelë”, që të ruhet origjinali.

    +
  • +
+ +

+ + + What are profiles? How can I switch between them? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

On first installation of Delta Chat a first profile is created.

+ +

Later, you can tap your profile image in the upper left corner to Add Profiles +or to Switch Profiles.

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

You may also wish to learn how to use the same profile on multiple devices.

+ +

+ + + Kush e sheh profilin tim? + + +

+ +
    +
  • +

    Mund të shtoni një foto profili te rregullimet tuaja. Nëse u shkruani kontakteve +tuaja ose i shtoni përmes kodi QR, e shohin automatikisht si foton e profilit tuaj.

    +
  • +
  • +

    Për arsye privatësie, askush s’e sheh foton tuaj të profilit, deri sa + t’u shkruani një mesazh.

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + Ç’do të thotë Fiksim, Heshtim, Arkivim? + + +

+ +

Përdorini këto mjete për të sistemuar fjalosjet tuaja dhe për ta mbajtur gjithçka në vendin e vet:

+ +
    +
  • +

    Fjalosjet e fiksuara qëndrojnë përherë në krye të listës së fjalosjeve. Mund t’i përdorni për të hyrë shpejt, ose përkohësisht te fjalosjet tuaja më të dashura, për të mos harruar gjëra.

    +
  • +
  • +

    Heshtoni fjalosje, nëse s’doni të merrni njoftime mbi to. Fjalosjet e heshtuara qëndrojnë në vend dhe mundeni edhe të fiksoni një fjalosje të heshtuar.

    +
  • +
  • +

    Arkivoni fjalosje, nëse s’doni t’i shihni më në listën tuaj të fjalosjeve. +Fjalosjet e arkivuara mbesin të përdorshme mbi listën e fjalosjeve, ose përmes kërkimit.

    +
  • +
  • +

    Kur te një fjalosje e arkivuar vjen një mesazh i ri, do të hapet jashtë arkivit dhe kalojë te lista juaj e fjalosjeve, veç në mos qoftë e heshtuar. +Fjalosjet e heshtuara mbeten të arkivuara, veç në i çarkivofshi dorazi.

    +
  • +
+ +

Që të arkivoni ose fiksoni një fjalosje, prekeni gjatë (në Android), përdorni menunë e fjalosjes (në Android/Desktop), ose fërkojeni për majtas (në iOS); +që të heshtoni një fjalosje, përdorni menunë e fjalosjes (në Android/Desktop), ose profilin e fjalosjes (në iOS).

+ +

+ + + How do “Saved Messages” work? + + +

+ +

Saved Messages is a chat that you can use to easily remember and find messages.

+ +
    +
  • +

    In any chat, long tap or right click a message and select Save

    +
  • +
  • +

    Saved messages are marked by the symbol +Saved icon +next to the timestamp

    +
  • +
  • +

    Later, open the “Saved Messages” chat - and you will see the saved messages there. +By tapping Arrow-right icon, +you can go back to the original message in the original chat

    +
  • +
  • +

    Finally, you can also use “Save Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

    +
  • +
  • +

    As “Saved Message” are synced, they can become very handy for transferring data between devices

    +
  • +
+ +

Messages stay saved even if they are edited or deleted - +may it be by sender, by device cleanup or by disappearing messages of other chats.

+ +

+ + + Ç’do të thotë pika e gjelbër? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Ç’duan të thonë shenjat e shfaqura pas mesazheve që dërgohen? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Correct typos and delete messages after sending + + +

+ +
    +
  • +

    You can edit the text of your messages after sending. +For that, long tap or right click the message and select Edit +or Edit icon.

    +
  • +
  • +

    If you have sent a message accidentally, +from the same menu, select Delete and then Delete for Everyone.

    +
  • +
+ +

While edited messages will have the word “Edited” next to the timestamp, +deleted messages will be removed without a marker in the chat. +Notifications are not sent and there is no time limit.

+ +

Note, that the original message may still be received by chat members +who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

+ +

+ + + How do disappearing messages work? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Note that you can rely on disappearing messages +only as long as you trust your chat partners; +malicious chat partners can take photos, +or otherwise save, copy or forward messages before deletion.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + Ç’ndodh, nëse aktivizoj “Fshi prej pajisjes mesazhe të vjetër”? + + +

+ +
    +
  • Nëse doni të kurseni hapësirë në pajisjen tuaj, mund të zgjidhni të fshihen +automatikisht mesazhe të vjetër.
  • +
  • Për ta aktivizuar, kaloni te “fshi prej pajisjeje mesazhe të vjetër”, te rregullimet +“Fjalosje & Media”. Mund të caktoni një periudhë nga “pas një ore” e deri +“pas një viti”; në këtë mënyrë, krejt mesazhet do të fshihen nga pajisja juaj +sapo të jenë më të vjetër se aq.
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + Krijimi i një grupi + + +

+ +
    +
  • Prej menusë në cepin e sipërm djathtas, përzgjidhni Fjalosje e re dhe mandej Grup i ri, ose shtypni butonin përgjegjës në Android/iOS.
  • +
  • Te skena vijuese, përzgjidhni anëtarë grupi dhe përcaktoni një emër grupi. Mund të përzgjidhni edhe një avatar grupi.
  • +
  • Sapo të shkruani mesazhin e parë te grupi, krejt anëtarët marrin vesh për grupin e ri dhe mund të përgjigjen në të (për sa kohë që te grupi s’shkruani një mesazh i cili është i padukshëm për anëtarët).
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Fshiva veten padashje. + + +

+ +
    +
  • Ngaqë s’jeni më anëtar i grupit, s’mund të shtoni veten sërish. +Megjithatë, s’ka problem, thjesht kërkojini një anëtari tjetër të grupit në një fjalosje të zakonshme t’ju shtojë sërish.
  • +
+ +

+ + + S’dua t’i marr më mesazhet e një grupi. + + +

+ +
    +
  • +

    Ose fshini veten si anëtar i listës, ose fshini krejt bisedën. +Nëse më vonë doni të ribëheni pjesë e grupit, kërkojini një anëtari tjetër të grupit t’ju shtojë sërish.

    +
  • +
  • +

    Ndryshe, mundeni edhe ta “Heshtoni” një grup - duke bërë këtë, do të merrni +krejt mesazhet dhe prapë mund të shkruani, por nuk njoftoheni më, +për çfarëdo mesazhesh të rinj.

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Instant message delivery and Push Notifications + + +

+ +

+ + + What are Push Notifications? How can I get instant message delivery? + + +

+ +

Push Notifications are sent by Apple and Google “Push services” to a user’s device +so that an inactive Delta Chat app can fetch messages in the background +and show notifications on a user’s phone if needed.

+ +

Push Notifications work with all chatmail servers on

+ +
    +
  • +

    iOS devices, by integrating with Apple Push services.

    +
  • +
  • +

    Android devices, by integrating with the Google FCM Push service, +including on devices that use microG +instead of proprietary Google code on the phone.

    +
  • +
+ +

+ + + Are Push Notifications enabled on iOS devices? Is there an alternative? + + +

+ +

Yes, Delta Chat automatically uses Push Notifications for chatmail profiles. +And no, there is no alternative on Apple’s phones to achieve instant message delivery +because Apple devices do not allow Delta Chat to fetch data in the background. +Push notifications are automatically activated for iOS users because +Delta Chat’s privacy-preserving Push Notification system +does not expose data to Apple that it doesn’t already have.

+ +

+ + + Are Push notifications enabled / needed on Android devices? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

In the Delta Chat “Notifications” settings for “Instant delivery” +you can change the following settings effecting all chat profiles:

+ +
    +
  • +

    Use Background Connection: If you are not using a Push service, +you may disable “battery optimizations” for Delta Chat, +allowing it to fetch messages in the background. +However, there could be delays from minutes to hours. +Some Android vendors even restrict apps completely +(see dontkillmyapp.com) +and Delta Chat might not show incoming messages +until you manually open the app again.

    +
  • +
  • +

    Force Background Connection: This is the fallback option +if the previous options are not available or do not achieve “instant delivery”. +Enabling it causes a permanent notification on your phone +which may sometimes be “minified” with recent Android phones.

    +
  • +
+ +

Both “Background Connection” options are energy-efficient and +safe to try if you experience messages arrive only with long delays.

+ +

+ + + How private are Delta Chat Push Notifications? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Here is how Delta Chat apps perform Push Notification delivery:

+ +
    +
  • +

    A Delta Chat app obtains a “device token” locally, encrypts it and stores it +on the chatmail server.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

The central Delta Chat notification proxy is small and fully implemented in Rust +and forgets about device-tokens as soon as Apple/Google/etc processed them, +usually in a matter of milliseconds.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

Resulting from this overall privacy design, even the seizure of a chatmail server, +or the full seizure of the central Delta Chat notification proxy +would not reveal private information that Push services do not already have.

+ +

+ + + Why does Delta Chat integrate with centralized proprietary Apple/Google push services? + + +

+ +

Delta Chat is a free and open source decentralized messenger with free server choice, +but we want users to reliably experience “instant delivery” of messages, +like they experience from WhatsApp, Signal or Telegram apps, +without asking questions up-front that are more suited to expert users or developers.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Multi-klient + + +

+ +

+ + + A mund ta përdor Delta Chat-in në shumë pajisje njëherësh në të njëjtën kohë? + + +

+ +

Po You can use the same profile on different devices:

+ +
    +
  • +

    Siguroni që të dyja pajisjet të gjenden në të njëjtin rrjet Wi-Fi, ose me fill

    +
  • +
  • +

    Te pajisja e parë, kaloni te Rregullime → Shtoni Pajisje të Dytë, shkyçni ekranin, në u dashtë +dhe prisni një çast sa të shfaqet një kod QR

    +
  • +
  • +

    Te pajisja e dytë, instaloni Delta Chat-in

    +
  • +
  • +

    Në pajisjen e dytë, nisni Delta Chat-in, përzgjidhni Shtoje si Pajisje të Dytë dhe skanoni kodin QR që nga pajisja e vjetër

    +
  • +
  • +

    Shpërngulja duhet të fillojë pas pak sekondash dhe, gjatë shpërnguljes, të dyja pajisjet do të shfaqin +ecurinë. Prisni deri sa të përfundojë në të dyja pajisjet.

    +
  • +
+ +

Ndryshe nga mjaft shkëmbyes të tjerë mesazhesh, pas një +shpërngulje të suksesshme, që të dyja pajisjet janë plotësisht të pavarura. +Njëra pajisja s’ka nevojë për tjetrën që të funksionojë.

+ +

+ + + Diagnostikim + + +

+ +
    +
  • +

    Kontrolloni sërish që të dyja pajisjet të gjenden në të njëjtin rrjet Wi-Fi ose klasik

    +
  • +
  • +

    On Windows, go to Control Panel / Network and Internet +and make sure, Private Network is selected as “Network profile type” +(after transfer, you can change back to the original value)

    +
  • +
  • +

    On iOS, make sure “System Settings / Apps / Delta Chat / Local Network” access is granted

    +
  • +
  • +

    On macOS, enable “System Settings / Privacy & Security / Local Network / Delta Chat”

    +
  • +
  • +

    Sistemi juaj mund të ketë një “firewall personal”, +që dihet se shkakton probleme (veçanërisht në Windows). +Çaktivizoni firewall-in personal për Delta Chat-in në të dy anët dhe riprovoni

    +
  • +
  • +

    Guest Networks may not allow devices to communicate with each other. +If possible, use a non-guest network.

    +
  • +
  • +

    If you still have troubles using the same network, +try to open Mobile Hotspot on one device and join that Wi-Fi from the other one

    +
  • +
  • +

    Garantoni se ka depozitë të mjaftueshme te pajisja vendmbërritje

    +
  • +
  • +

    Nëse shpërngulja nisni, siguroni që pajisja mbetet aktive dhe nuk bie në gjumë. +Mos mbyllni Delta Chat-in. +(po punojmë fort për ta bërë aplikacionin të funksionojë në prapaskenë, por sistemet priren t’i asgjësojnë aplikacionet, për fat të keq)

    +
  • +
  • +

    A është bërë tashmë hyrja te llogaria Delta Chat në pajisjen vendmbërritje? +Mund të përdorni llogari të shumta për pajisje, thjesht shtoni llogari tjetër

    +
  • +
  • +

    Nëse keni ende probleme, ose s’mundeni të skanoni një kod QR +provoni shpërnguljen dorazi të përshkruar më poshtë

    +
  • +
+ +

+ + + Manual Transfer + + +

+ +

Kjo metodë rekomandohet vetëm nëse “Shtoni Pajisje të Dytë” si përshkruhet më sipër s’funksionon.

+ +
    +
  • On the old device, go to “Settings -> Chats and media -> Export Backup”. Enter your +screen unlock PIN, pattern, or password. Then you can click on “Start +Backup”. This saves the backup file to your device. Now you have to transfer +it to the other device somehow.
  • +
  • On the new device, in the “I already have a profile” menu, +choose “restore from backup”. After import, your conversations, encryption +keys, and media should be copied to the new device. +
      +
    • If you use iOS: and you encounter difficulties, maybe +this guide will +help you.
    • +
    +
  • +
  • You are now synchronized, and can use both devices for sending and receiving +end-to-end encrypted messages with your communication partners.
  • +
+ +

+ + + A ka ndonjë plan për të sjellë një Klient Web Delta Chat? + + +

+ +
    +
  • S’ka plane të afërta, por ca mendime paraprake.
  • +
  • Ka 2-3 rrugë për sjelljen e një Klienti Web Delta Chat, por që të gjitha +duan punë të madhe. Tani për tani, jemi përqendruar në pasjen e hedhjeve +të qëndrueshme në qarkullim në krejt shitoret e aplikacioneve (depo +Google Play/iOS/Windows/macOS/Linux) si aplikacione origjinale të sistemit +përkatës.
  • +
  • Nëse ju duhet një Klient Web, ngaqë s’keni leje të instaloni software në +kompjuterin me të cilin punoni, mund të përdorni Klientin e bartshëm për +Windows Desktop, ose AppImage për Linux. Mund t’i gjeni te +get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Më interesojnë hollësitë teknike. Mund të më tregoni diçka më tepër? + + +

+ + + +

+ + + Fshehtëzim dhe Siguri + + +

+ +

+ + + Cilët standarde përdoren për fshehtëzim skaj-më-skaj? + + +

+ +

Delta Chat uses a secure subset of the OpenPGP standard +to provide automatic end-to-end encryption using these protocols:

+ +
    +
  • +

    Secure-Join +to exchange encryption setup information through QR-code scanning or “invite links”.

    +
  • +
  • +

    Autocrypt is used for automatically +establishing end-to-end encryption between contacts and all members of a group chat.

    +
  • +
  • +

    Sharing a contact to a +chat +enables receivers to use end-to-end encryption with the contact.

    +
  • +
+ +

Delta Chat does not query, publish or interact with any OpenPGP key servers.

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

All messages in Delta Chat are end-to-end encrypted by default. +Since the Delta Chat Version 2 release series (July 2025) +there are no lock or similar markers on end-to-end encrypted messages, anymore.

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

If you use default chatmail relays, +it is impossible to receive or send messages without end-to-end encryption.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

For more in-depth discussion of “guaranteed end-to-end encryption” +please see Secure-Join protocols +and specifically read about “Verified Groups”, the technical term +of what is called here “green-checkmarked” or “guaranteed end-to-end encrypted” chats.

+ +

+ + + Are attachments (pictures, files, audio etc.) end-to-end encrypted? + + +

+ +

Po

+ +

When we talk about an “end-to-end encrypted message” +we always mean a whole message is encrypted, +including all the attachments +and attachment metadata such as filenames.

+ +

+ + + A është i siguruar OpenPGP? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP is not insecure by itself. +Most publicly discussed OpenPGP security problems +actually stem from bad usability or bad implementations of tools or apps (or both). +It is particularly important to distinguish between OpenPGP, the IETF encryption standard, +and GnuPG (GPG), a command line tool implementing OpenPGP. +Many public critiques of OpenPGP actually discuss GnuPG which Delta Chat has never used. +Delta Chat rather uses the OpenPGP Rust implementation rPGP, +available as an independent “pgp” package, +and security-audited in 2019 and 2024.

+ +

We aim, along with other OpenPGP implementors, +to further improve security characteristics by implementing the +new IETF OpenPGP Crypto-Refresh +which was thankfully adopted in summer 2023.

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat takes a holistic “usable security” approach +and works with a wide range of activist groupings as well as +renowned researchers such as TeamUSEC +to improve actual user outcomes against security threats. +The wire protocol and standard for establishing end-to-end encryption is +only one part of “user outcomes”, +see also our answers to device-seizure +and message-metadata questions.

+ +

+ + + A mund të preket Delta Chat-i nga EFAIL? + + +

+ +

Jo, Delta Chat s’qe kurrë i cenueshëm nga EFAIL +ngaqë sendërtimi në të i OpenPGP-së rPGP +përdor Kod Pikasje Ndryshimesh, kur fshehtëzohen mesazhe +dhe shfaq një gabim +nëse Kodi i Pikasjes së Ndryshimeve është i pasaktë.

+ +

Delta Chat also never was vulnerable to the “Direct Exfiltration” EFAIL attack +because it only decrypts multipart/encrypted messages +which contain exactly one encrypted and signed part, +as defined by the Autocrypt Level 1 specification.

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + Si i mbron Delta Chat-i tejtëdhënat në mesazhe? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + Si të mbrohen tejtëdhënat dhe kontaktet, kur shtien në dorë një pajisje? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Does Delta Chat support Perfect Forward Secrecy? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

You may check the end-to-end encryption status manually in the “Encryption” dialog +(user profile on Android/iOS or right-click a user’s chat-list item on desktop). +Delta Chat shows two fingerprints there. +If the same fingerprints appear on your own and your contact’s device, +the connection is safe.

+ +

+ + + A mund të ripërdor kyçin tim ekzistues privat? + + +

+ +

No.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + A është bërë auditim i pavarur i Delta Chat-it për cenueshmëri sigurie? + + +

+ +

Yes, multiple times. +The Delta Chat project continuously undergoes independent security audits and analysis, +from most recent to older:

+ +
    +
  • +

    2024 December, an NLNET-commissioned Evaluation of +rPGP by Radically Open Security took place. +rPGP serves as the end-to-end encryption OpenPGP engine of Delta Chat. +Two advisories were released related to the findings of this audit:

    + + + +

    The issues outlined in these advisories have been fixed and are part of Delta Chat +releases on all appstores since December 2024.

    +
  • +
  • +

    2024 March, we received a deep security analysis from the Applied Cryptography +research group at ETH Zuerich and addressed all raised issues. +See our blog post about Hardening Guaranteed End-to-End encryption for more detailed information and the +Cryptographic Analysis of Delta Chat +research paper published afterwards.

    +
  • +
  • +

    Në fillim të 2023-shit, ndreqëm probleme sigurie dhe privatësie me +veçorinë “aplikacione web dhënë në një fjalosje”, të lidhura me dështime +në izolimin e tyre, veçanërisht nën Chromium. Në vazhdim kaluam një auditim +të pavarur sigurie prej Cure53 dhe krejt problemet e gjetura u ndreqën në +seritë 1.36 të hedhura në qarkullim në prill të 2023-shit. +Shihni këtu, për shpjegim të plotë të sfondit për sigurinë lidhur me E2E në web.

    +
  • +
  • +

    Në fillim të 2023-it, Cure53 analizoi qoftë fshehtëzimin +e transporteve për lidhje rrjeti të Delta Chat-it, qoftë një formësim të riprodhueshëm +shërbyesi poste si të rekomanduarin në këtë sajt. +Mund të lexoni më tepër rreth auditimit në blogun tonë, +ose të lexoni raportin e plotë këtu.

    +
  • +
  • +

    Më 2020-n, Include Security analizoi bazën +në Rust të Delta Chat-it, si dhe bibliotekat IMAP, SMTP +dhe TLS. +S’gjeti ndonjë problem kritik apo të rëndësisë së madhe. +Raporti ngriti pak dobësi të rëndësisë mesatare - +ato në vetvete s’përbëjnë kërcënim për përdoruesit e Delta Chat-it +ngaqë varen në mjedisin në të cilin përdoret Delta Chat-i. +Për arsye përputhjeje dhe përdorimi, +s’mund t’i shmangim krejt ato +dhe vendosëm të ofrojmë rekomandime sigurie për përdoruesit e kërcënuar. +Mund të lexoni raportin e plotë këtu.

    +
  • +
  • +

    Më 2019-n, Include Security analizoi libraritë +PGP dhe +RSA të Delta Chat-it. +S’gjeti probleme kritike, +por dy çështje me rëndësi të lartë që i ndreqim në vazhdim. +Nxori gjithashtu një çështje të rëndësisë mesatare dhe disa çështje me më pak rëndësi, +por s’kishte ndonjë rrugë për t’i shfrytëzuar këto cenueshmëri në sendërtimin e Delta Chat-it. +Pavarësisht, disa nga këto i ndreqëm që kur përfundoi auditimi. +Mund të lexoni raportin e plotë këtu.

    +
  • +
+ +

+ + + Të ndryshme + + +

+ +

+ + + Ç’leje lyp Delta Chat-i? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + Where can my friends find Delta Chat? + + +

+ +

Delta Chat is available for all major and some minor platforms:

+ + + +

+ + + Si financohet zhvillimi i Delta Chat-it? + + +

+ +

Delta Chat nuk përfiton ndonjë financim të llojit Venture Capital dhe s’ka +borxhe, as gjendet nën trysni për të prodhuar fitime të mëdha, apo për të +shitur përdoruesit, shokët dhe familjen e tyre reklamuesve (apo më keq). +Në vend të kësaj, përdorim burime financimi publik, deri sot me origjinë nga +BE dhe ShBA, si ndihmë të përpjekjeve tona për lulëzimin e një ekosistemi të +decentralizuar dhe të larmishëm shkëmbimi mesazhesh bazuar në zhvillime +bashkësie të Lirë dhe Me Burim të Hapët.

+ +

Concretely, Delta Chat developments have so far been funded from these sources, +ordered chronologically:

+ +
    +
  • +

    The NEXTLEAP EU project funded the research +and implementation of verified groups and setup contact protocols +in 2017 and 2018 and also helped to integrate end-to-end Encryption +through Autocrypt.

    +
  • +
  • +

    Open Technology Fund na dha grantin e parë +për 2018/2019 (~200 mijë dollarë) me të cilin përmirësuam ndjeshëm aplikacionin +për Android dhe hodhëm në qarkullim një version të parë beta aplikacioni për Desktop, +si dhe i afroi më tepër zhvillimet tona për veçori me kërkime UX në kontekste të drejtash të njeriut, +shihni raportin tonë përfundimtar “Needfinding and UX”. +Granti i dytë për 2019/2020 (~$300K) na ndihmoi të hedhim në qarkullim +versione Delta/iOS, për të shndërruar bibliotekën tonë bazë në Rust, si dhe +për të sjellë veçori të reja për krejt platformat.

    +
  • +
  • +

    The NLnet foundation granted in 2019/2020 EUR 46K for +completing Rust/Python bindings and instigating a Chat-bot eco-system.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 we successfully completed the OTF-funded +Secure Chatmail project, +allowing us to introduce guaranteed encryption, +creating a chatmail server network +and providing “instant onboarding” in all apps released from April 2024 on.

    +
  • +
  • +

    In 2023 and 2024 we got accepted in the Next Generation Internet (NGI) +program for our work in webxdc PUSH, +along with collaboration partners working on +webxdc evolve, +webxdc XMPP, +DeltaTouch and +DeltaTauri. +All of these projects are partially completed or to be completed in early 2025.

    +
  • +
  • +

    Ndonjëherë marrim dhurime unike nga individë privatë. +Për shembull, më 2021-shin një individ bujar na dërgoi një dërgesë +bankare prej 4K eurosh me subjektin “vazhdoni zhvillimin e mbarë!”. 💜 +Këto para i përdorim për të zhvilluar takime, ose mbuluar shpenzime ad-hoc +që s’mund të parashikohen kollaj, ose të rimbursohen nga grante financimesh publike. +Marrja e më shumë dhurimeve na ndihmon gjithashtu të bëhemi më të pavarur dhe +të jetëgjatë, si bashkësi kontribuesish.

    + + +
  • +
  • +

    E fundit, por aspak për nga rëndësia, disa ekspertë dhe entuziastë kanë dhënë +dhe japin ndihmesë pro-bono në zhvillimin e Delta Chat-it pa përfituar para, +ose vetëm shuma të vogla. Pa ta, Delta Chat-i s’do të ish atje ku është sot, +madje as afër asaj.

    +
  • +
+ +

Financimi monetar i përmendur më sipër është kryesisht i organizuar nga GmbH në +Frajburg (Gjermani) dhe u shpërndahet më tepër se një duzine kontribuesish nga e gjithë bota.

+ +

Për mundësi kontributesh monetare ose lloji tjetër, ju lutemi, shihni +kanale kontributi te Delta Chat.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/tick1.png b/src/main/assets/help/tick1.png new file mode 100644 index 0000000000000000000000000000000000000000..986201dfbbcb9171b6a5c4680c848e12693b754b Binary files /dev/null and b/src/main/assets/help/tick1.png differ diff --git a/src/main/assets/help/tick2.png b/src/main/assets/help/tick2.png new file mode 100644 index 0000000000000000000000000000000000000000..ea6f08f0b62773b87dbee724d264cfad7ec19fca Binary files /dev/null and b/src/main/assets/help/tick2.png differ diff --git a/src/main/assets/help/uk/help.html b/src/main/assets/help/uk/help.html new file mode 100644 index 0000000000000000000000000000000000000000..30c8aedb0c9f1fc7068ff5117b31a3d7b0f0da7e --- /dev/null +++ b/src/main/assets/help/uk/help.html @@ -0,0 +1,1435 @@ + + + + + +

+ + + Що таке Delta Chat? + + +

+ +

Delta Chat це надійний, децентралізований застосунок, для безпечного та миттєвого спілкування, +доступний на мобільних та настільних платформах.

+ + + +

+ + + Як мені знайти людей для спілкування? + + +

+ +

По-перше, зверніть увагу, що Delta Chat - це приватний месенджер. Тут немає публічного розкриття, ви самі вирішуєте, з ким спілкуватися.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Зараз зачекайте, поки встановлюється з’єднання.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Вітаємо! +Тепер Ви автоматично використовуватимете наскрізне шифрування з цим контактом. +Якщо ви додасте один одного у групи, наскрізне шифрування буде встановлено між усіма учасниками.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    ви потрібно прийняти запит, перш ніж ви зможете відповісти.

    +
  • +
  • +

    Ви також можете “видалити” його, якщо ви не хочете зараз спілкуватися з ними.

    +
  • +
  • +

    Якщо ви видалите запит, майбутні повідомлення від цього контакту все одно відображатимуться як запит на повідомлення, щоб ви могли змінити свою думку. Якщо дуже не хочеться отримувати повідомлення від цієї особи, подумайте про її блокування.

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Чи підтримує Delta Chat вкладення у вигляді фото, відео тощо? + + +

+ +
    +
  • +

    Так. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    З міркувань продуктивності, зображення оптимізовані та надсилаються в меншому розмірі за замовчуванням, але ви можете надіслати їх як «файл», щоб зберегти оригінал.

    +
  • +
+ +

+ + + Що таке профілі? Як я можу перемикатися між ними? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

Під час першого встановлення Delta Chat створюється перший профіль.

+ +

Пізніше ви можете натиснути на зображення вашого профілю у верхньому лівому кутку, щоб вибрати Додати профілі або Переключити профілі.

+ +

Ви можете використовувати окремі профілі для політичної, сімейної або робочої діяльності.

+ +

Ви також можете дізнатися як використовувати один і той самий профіль на декількох пристроях.

+ +

+ + + Хто бачить моє зображення профілю? + + +

+ +
    +
  • +

    Ви можете додати зображення профілю в ваших налаштуваннях. Якщо ви пишете комусь із ваших контактів чи додаєте їх через QR код, вони автоматично побачать ваше зображення профілю.

    +
  • +
  • +

    Із міркувань приватності, ніхто не бачить ваше зображення профілю доки ви їм не напишете.

    +
  • +
+ +

+ + + Чи можу я встановити біографію/статус у Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + Що значить Закріплення, Приглушення, Архівування? + + +

+ +

Використовуйте ці інструменти, щоб організувати ваші чати і тримати все на своєму місці:

+ +
    +
  • +

    Закріплені чати завжди залишаються першими в списку чатів. Ви можете використовувати їх, щоб мати швидкий доступ до ваших найулюбленіших чат або тимчасово аби про щось не забути.

    +
  • +
  • +

    Приглушіть чати якщо ви не хочете отримувати сповіщення для них. Приглушені чати залишаються на місці і ви також можете закріпити приглушений чат.

    +
  • +
  • +

    Архівуйте чати, якщо ви більше не хочете бачити їх у своєму списку чатів. +Заархівовані чати залишаються доступними над списком чатів або через пошук.

    +
  • +
  • +

    Коли архівний чат отримує нове повідомлення, якщо не приглушений, він вискочить з архіву і повернеться у ваш список чатів. +Приглушені чати залишаються заархівованим доки ви не розархівуєте їх вручну.

    +
  • +
+ +

Щоб скористатися функціями, утримуйте натиснутою клавішу або клацніть правою кнопкою миші на чаті у списку чатів.

+ +

+ + + Як працюють “Збережені повідомлення”? + + +

+ +

Збережені повідомлення - це чат, за допомогою якого ви можете легко запам’ятовувати та знаходити повідомлення.

+ +
    +
  • +

    У будь-якому чаті натисніть і утримуйте повідомлення або клацніть правою кнопкою миші та виберіть Зберегти.

    +
  • +
  • +

    Збережені повідомлення позначені символом Saved icon поруч з міткою часу

    +
  • +
  • +

    Пізніше відкрийте чат “Збережені повідомлення” - і ви побачите там збережені повідомлення. Натиснувши Arrow-right icon, ви можете повернутися до початкового повідомлення в оригінальному чаті

    +
  • +
  • +

    Нарешті, ви також можете використовувати “Зберегти повідомлення”, щоб робити особисті нотатки - відкрити чат, набрати щось, додати фотографію або голосове повідомлення тощо.

    +
  • +
  • +

    Оскільки “Збережені повідомлення” синхронізуються, вони можуть стати дуже зручними для передачі даних між пристроями

    +
  • +
+ +

Повідомлення залишаються збереженими, навіть якщо вони були відредаговані або видалені - чи то через відправника, чи то через очищення пристрою, чи то через зникнення повідомлень інших чатів.

+ +

+ + + Що означає зелена точка? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + Що означають галочки біля вихідних повідомлень? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + Виправлення помилок та видалення повідомлень після надсилання + + +

+ +
    +
  • +

    Ви можете редагувати текст ваших повідомлень після відправлення. Для цього натисніть на повідомлення довгим натисканням або правою кнопкою миші і виберіть Редагувати або Edit icon.

    +
  • +
  • +

    Якщо ви випадково надіслали повідомлення, в тому ж меню виберіть Видалити, а потім Видалити для всіх.

    +
  • +
+ +

Відредаговані повідомлення матимуть позначку “Відредаговано” поруч з міткою часу, видалені повідомлення будуть видалені без позначки в чаті. Сповіщення не надсилаються і відсутнє часове обмеження.

+ +

Зауважте, що початкове повідомлення все ще може бути отримане учасниками чату які могли вже відповісти, переслати, зберегти, зробити знімок екрану або іншим чином скопіювати повідомлення.

+ +

+ + + Як працюють повідомлення, що зникають? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

Зверніть увагу, що ви можете покладатися на зникнення повідомлень лише доти, доки ви довіряєте своїм співрозмовникам; зловмисники можуть робити фотографії, або іншим чином зберігати, копіювати або пересилати повідомлення перед видаленням.

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + Що станеться, якщо я ввімкну «Видаляти старі повідомлення з пристрою»? + + +

+ +
    +
  • Якщо ви хочете заощадити пам’ять на своєму пристрої, ви можете видалити старе повідомлення автоматично.
  • +
  • Щоб увімкнути його, перейдіть до «видалити старі повідомлення з пристрою» в налаштуваннях «Чатів та медіа» . Ви можете встановити часові рамки від «через годину» до «через рік»; +Таким чином, усі повідомлення будуть видалені з вашого пристрою, як тільки вони будуть старішими за це.
  • +
+ +

Як я можу видалити свій профіль у Delta Chat? {#remove-account}

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Групи + + +

+ +

Групи дозволяють кільком людям спілкуватися у приватному чаті з рівними правами.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Оскільки усі учасники мають однакові права, групи найкраще працюють серед довірених друзів та родичів.

+ +

+ + + Створення групи + + +

+ +
    +
  • Оберіть Новий чат, потім Нова групи у меню в верхньому правому кутку або натисніть відповідну кнопку у Android/iOS.
  • +
  • На наступному екрані виберіть учасники групи та встановіть назву групи. Ви також можете обрати аватар групи.
  • +
  • Як тільки ви напишете перше повідомлення у групу, усі учасники будуть проінформовані про нову групу і зможуть відповісти у нову групу (доки ви не напишете повідомлення у групі, група залишатиметься невидимою для учасників).
  • +
+ +

+ + + Додавання та видалення учасників + + +

+ +
    +
  • +

    Усі учасники групи мають однакові права. +Тому кожен може видалити будь-якого учасника або додати нових.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + Я випадково себе видалив + + +

+ +
    +
  • Оскільки ви більше не учасник групи, ви не зможете додати себе знову. Однак, це не проблема, просто попросіть будь-якого іншого учасника групи в звичайному чаті додати вас знову.
  • +
+ +

+ + + Я більше не хочу отримувати повідомлення групи. + + +

+ +
    +
  • +

    Або видаліть себе із списку учасників групи, або видаліть весь чат. Якщо ви хочете повернутись до чату пізніше, попросіть іншого учасника групи додати вас знову.

    +
  • +
  • +

    Ви також можете “Заглушити” групу - це означає, що ви будете отримувати усі повідомлення та можете писати у групу, але ви більше не будете отримувати сповіщення про нові повідомлення.

    +
  • +
+ +

+ + + Клонування групи + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

Нова група є цілком незалежною від оригінальної, +котра продовжує працювати як раніше.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    Все інше, що Вам потрібно знати, написано у +документації Webxdc.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + Миттєва доставка повідомлень та Push-сповіщення + + +

+ +

+ + + Що таке push-сповіщення? Як отримати миттєву доставку повідомлень? + + +

+ +

Push-повідомлення надсилаються “Push-сервісами” Apple та Google на пристрій користувача щоб неактивний додаток Delta Chat міг отримувати повідомлення у фоновому режимі і показувати сповіщення на телефоні користувача, якщо це необхідно.

+ +

Push-сповіщення працюють з усіма chatmail-серверами на

+ +
    +
  • +

    iOS-пристроях, інтегруючись із сервісами Apple Push.

    +
  • +
  • +

    Android-пристроях, шляхом інтеграції з сервісом Google FCM Push, в тому числі на пристроях, які використовують microG замість пропрієтарного коду Google на телефоні.

    +
  • +
+ +

+ + + Чи ввімкнено Push-сповіщення на пристроях iOS? Чи є альтернатива? + + +

+ +

Так, Delta Chat автоматично використовує push-повідомлення для профілів chatmail. +І ні, на телефонах Apple немає альтернативи миттєвій доставці повідомлень тому, що пристрої Apple не дозволяють Delta Chat отримувати дані у фоновому режимі. +Push-сповіщення автоматично активуються для користувачів iOS, тому що система push-сповіщень Delta Chat, що зберігає конфіденційність не передає Apple дані, якими вона ще не володіє.

+ +

+ + + Чи ввімкнені/потрібні Push-сповіщення на пристроях Android? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

У налаштуваннях “Сповіщення” Delta Chat для “Миттєва доставка” ви можете змінити наступні налаштування, які впливають на всі профілі чату:

+ +
    +
  • +

    Використовувати фонове підключення: Якщо ви не використовуєте Push-сервіс, ви можете вимкнути “оптимізацію батареї” для Delta Chat, що дозволить йому отримувати повідомлення у фоновому режимі. Однак це може призвести до затримок від декількох хвилин до декількох годин. Деякі постачальники Android навіть повністю обмежують додатки (див. dontkillmyapp.com) і Delta Chat може не показувати вхідні повідомлення доки ви не відкриєте програму вручну.

    +
  • +
  • +

    Примусове фонове підключення: Це запасний варіант якщо попередні опції недоступні або не досягають “миттєвої доставки”. Увімкнення цієї опції спричиняє постійне сповіщення на вашому телефоні яке іноді може бути “мінімізоване” на останніх версіях телефонів Android.

    +
  • +
+ +

Обидва варіанти “Фонового з’єднання” є енергоефективними та безпечні, якщо ви відчуваєте, що повідомлення надходять із великими затримками.

+ +

+ + + Наскільки приватними є push-сповіщення Delta Chat? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

Ось як програми Delta Chat здійснюють доставку push-сповіщень:

+ +
    +
  • +

    Додаток Delta Chat отримує “токен пристрою” локально, шифрує його і зберігає на сервері chatmail.

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

Центральний проксі для сповіщень Delta Chat невеликий і повністю реалізований на Rust забуває про токени пристроїв, як тільки Apple/Google/etc обробили їх, зазвичай за лічені мілісекунди.

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

В результаті цього загального дизайну конфіденційності, навіть захоплення chatmail-сервера, або повне вилучення центрального проксі-сервера повідомлень Delta Chat не призведе до розкриття приватної інформації, якої ще не мають Push-сервіси.

+ +

+ + + Чому Delta Chat інтегрується з централізованими пропрієтарними push-сервісами Apple/Google? + + +

+ +

Delta Chat - це безкоштовний децентралізований месенджер з відкритим вихідним кодом і вільним вибором сервера, але ми хочемо, щоб користувачі отримували надійну “миттєву доставку” повідомлень, як у додатках WhatsApp, Signal або Telegram, без попередніх запитань, які більше підходять для досвідчених користувачів або розробників.

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + Мульти-клієнт + + +

+ +

+ + + Чи можна використовувати Delta Chat на декількох пристроях одночасно? + + +

+ +

Так, ви можете використовувати один і той самий профіль на різних пристроях:

+ +
    +
  • +

    Упевніться, що обидва пристрої підключені до одного Wi-Fi або мережі

    +
  • +
  • +

    На першому пристрої перейдіть до Налаштування → Додати другий пристрій, розблокуйте екран, якщо потрібно і трохи зачекайте, поки не з’явиться QR-код

    +
  • +
  • +

    На другому пристрої встановіть Delta Chat

    +
  • +
  • +

    На другому пристрої запустіть Delta Chat, виберіть Додати як другий пристрій і відскануйте QR-код зі старого пристрою

    +
  • +
  • +

    Передача має розпочатися через кілька секунд, і під час передачі обидва пристрої відображатимуть прогрес. Дочекайтеся завершення на обох пристроях.

    +
  • +
+ +

На відміну від багатьох інших месенджерів, після успішної передачі, обидва пристрої повністю незалежні. Один пристрій не потрібен для роботи іншого.

+ +

+ + + Вирішення проблем + + +

+ +
    +
  • +

    Ще раз упевніться, що обидва пристрої підключені до одного Wi-Fi або мережі

    +
  • +
  • +

    У Windows перейдіть до Панель керування / Мережа та Інтернет і переконайтеся, що Приватна мережа вибрано як “Тип мережевого профілю” (після перенесення ви можете повернути початкове значення)

    +
  • +
  • +

    На iOS переконайтеся, що доступ до “Системні налаштування / Програми / Delta Chat / Локальна мережа” дозволено

    +
  • +
  • +

    У macOS увімкніть “Системні налаштування / Конфіденційність і безпека / Локальна мережа / Delta Chat”

    +
  • +
  • +

    Ваша система може мати “персональний брандмауер”, який, як відомо, викликає проблеми (особливо у Windows). Вимкніть персональний брандмауер для Delta Chat на обох кінцях і повторіть спробу

    +
  • +
  • +

    Гостьові мережі можуть не дозволяти пристроям зв’язуватися один з одним. Якщо можливо, використовуйте негостьову мережу.

    +
  • +
  • +

    Якщо у вас все ще виникають проблеми з використанням однієї мережі, спробуйте відкрити Мобільну точку доступу на одному пристрої та приєднатися до цього Wi-Fi з іншого

    +
  • +
  • +

    Переконайтеся, що на цільовому пристрої достатньо пам’яті

    +
  • +
  • +

    Якщо передача почалася, переконайтеся, що пристрої залишаються активними і не засинають. Не виходьте з Delta Chat. (ми докладаємо всіх зусиль, щоб програма працювала у фоновому режимі, але системи, як правило, вбивають програми, на жаль)

    +
  • +
  • +

    Delta Chat вже зареєстрований на цільовому пристрої? Ви можете використовувати кілька профілів на одному пристрої, просто додайте ще один профіль

    +
  • +
  • +

    Якщо у вас усе ще виникають проблеми або якщо ви не можете відсканувати QR-код спробуйте перенесення вручну, описане нижче

    +
  • +
+ +

+ + + Ручне перенесення + + +

+ +

Цей спосіб рекомендований, лише якщо «Додати другий пристрій», як описано вище, не працює.

+ +
    +
  • На старому пристрої перейдіть до “Налаштування -> Чати та медіа -> Експортувати резервну копію”. Введіть свій PIN-код, графічний ключ або пароль розблокування екрана. Потім ви можете натиснути на “Почати резервне копіювання”. Це збереже файл резервної копії на вашому пристрої. Тепер вам потрібно якось перенести його на інший пристрій.
  • +
  • На новому пристрої в меню “У мене вже є профіль” виберіть “Відновити з резервної копії”. Після імпорту ваші розмови, ключі шифрування ключі шифрування та медіа повинні бути скопійовані на новий пристрій.
  • +
  • Якщо ви користуєтеся iOS:** і у вас виникли труднощі, можливо цей посібник допоможе вам.
  • +
  • Тепер ви синхронізовані і можете використовувати обидва пристрої для надсилання та отримання наскрізних зашифрованих повідомлень зі своїми партнерами по спілкуванню.
  • +
+ +

+ + + Чи планується впровадження веб-клієнта Delta Chat? + + +

+ +
    +
  • Немає найближчих планів, крім попередніх думок.
  • +
  • Є 2-3 способи створити веб-клієнт Delta Chat, але всі вони вимагають значної роботи. На даний момент ми зосереджені на отриманні стабільних релізів в усіх магазини додатків (Google Play / IOS / Windows / MacOS / репозитаріїв Linux), як нативних додатків.
  • +
  • Якщо вам потрібен веб-клієнт, через заборону встановлювати програмне забезпечення на комп’ютері, з яким ви працюєте, ви можете використовувати портативний клієнт Windows Desktop або AppImage для Linux. Ви можете знайти їх на get.delta.chat.
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Експериментальні функції + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

Ці функції можуть бути нестабільними та можуть бути змінені або видалені.

+ +

Ви можете знайти додаткову інформацію +та залишити відгук на форумі.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

Коли Ви увімкнете цю функцію, +щотижнева статистика буде автоматично надсилатися боту.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

Ми не будемо збирати жодних персональних даних, які б могли ідентифікувати Вас особисто.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + Я хочу керувати власним сервером для Delta Chat. Що ви порекомендуєте? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + Мене цікавлять технічні деталі. Можете розповісти більше? + + +

+ + + +

+ + + Шифрування та безпека + + +

+ +

+ + + Які стандарти використовуються для наскрізного шифрування? + + +

+ +

Delta Chat використовує безпечну підмножину стандарту OpenPGP для забезпечення автоматичного наскрізного шифрування за допомогою цих протоколів:

+ +
    +
  • +

    Використовуйте Secure-Join щоб обмінюватися інформацією про налаштування шифрування, через сканування QR-коду або “посилання-запрошення”.

    +
  • +
  • +

    Autocrypt використовується для автоматичного встановлення наскрізного шифрування між контактами і всіма учасниками групового чату.

    +
  • +
  • +

    Поширення контакту в чаті дозволяє отримувачам використовувати наскрізне шифрування з контактом.

    +
  • +
+ +

Delta Chat не запитує, не публікує і не взаємодіє з будь-якими серверами ключів OpenPGP.

+ +

+ + + Як дізнатися, чи повідомлення зашифровано наскрізним шифруванням? + + +

+ +

Всі повідомлення в Delta Chat за замовчуванням наскрізно зашифровані. Починаючи з версії 2 Delta Chat (липень 2025 року) на наскрізних зашифрованих повідомленнях більше немає замків або інших подібних маркерів.

+ +

+ + + Чи можу я отримувати та надсилати пошту без наскрізного шифрування? + + +

+ +

Якщо ви використовуєте стандартні ретранслятори чату, неможливо отримувати або надсилати повідомлення без наскрізного шифрування.

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + Що означає зелена галочка в профілі контакту? + + +

+ +

У профілі контакту може відображатися зелена галочка green checkmark і рядок “Представлений”. Кожен контакт із зеленою галочкою або зробив пряме QR-сканування з вами або був представлений іншим контактом, позначеним зеленою галочкою. Знайомство відбувається автоматично під час додавання учасників до груп. Той, хто додає контакт із зеленою галочкою до групи, в якій є лише учасники із зеленою галочкою стає представником. У профілі контакту ви можете кілька разів натиснути на текст “Представлений …” поки не потрапите до того, з ким ви безпосередньо зробили QR-сканування.

+ +

Для більш детального обговорення “гарантованого наскрізного шифрування” будь ласка, перегляньте Протоколи безпечного приєднання і, зокрема, прочитайте про “Перевірені групи”, технічний термін того, що тут називається чатами з “зеленою галочкою” або “гарантованим наскрізним шифруванням”.

+ +

+ + + Чи зашифровані наскрізно вкладення (зображення, файли, аудіо тощо)? + + +

+ +

Так.

+ +

Коли ми говоримо про “наскрізно зашифроване повідомлення” ми завжди маємо на увазі, що зашифровано все повідомлення, включно з усіма вкладеннями і метадані вкладень, такі як імена файлів.

+ +

+ + + Чи безпечний OpenPGP? + + +

+ +

Так, Delta Chat використовує безпечну підмножину OpenPGP яка вимагає, щоб все повідомлення було належним чином зашифровано і підписано. Наприклад, “відокремлені підписи” не вважаються безпечними.

+ +

OpenPGP сам по собі не є небезпечним. Більшість публічно обговорюваних проблем безпеки OpenPGP насправді виникають через недостатню зручність використання або погану реалізацію інструментів чи програм (або обох). +Особливо важливо розрізняти OpenPGP, стандарт шифрування IETF, і GnuPG (GPG), інструмент командного рядка, що реалізує OpenPGP. +Багато публічних критиків OpenPGP насправді обговорюють GnuPG, який Delta Chat ніколи не використовував. +Delta Chat скоріше використовує реалізацію OpenPGP Rust rPGP, доступну як незалежний пакет «pgp» і перевірку безпеки в 2019 і 2024 роках.

+ +

Ми прагнемо, разом з іншими розробниками OpenPGP, подальше покращення характеристик безпеки шляхом впровадження нового IETF OpenPGP Crypto-Refresh який, на щастя, був прийнятий влітку 2023 року.

+ +

+ + + Чи розглядали ви можливість використання альтернатив OpenPGP для наскрізного шифрування? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat застосовує цілісний підхід “корисної безпеки” і працює з широким спектром активістських груп, а також відомими дослідниками, такими як TeamUSEC щоб покращити реальний захист користувачів від загроз безпеці. Протокол і стандарт для створення наскрізного шифрування - це лише одна частина “результатів для користувача”, див. також наші відповіді на вилучення пристрою та метадані повідомлення.

+ +

+ + + Чи вразливий Delta Chat до EFAIL? + + +

+ +

Ні, Delta Chat ніколи не був вразливим до EFAIL тому що його реалізація OpenPGP rPGP використовує код виявлення модифікацій при шифруванні повідомлень і повертає помилку якщо код виявлення модифікацій невірний.

+ +

Delta Chat також ніколи не був вразливим до EFAIL-атаки “Пряма ексфільтрація” тому що він розшифровує лише “багатокомпонентні/зашифровані” повідомлення, які містять рівно одну зашифровану і підписану частину, як визначено специфікацією Autocrypt Level 1.

+ +

+ + + Чи повідомлення, позначені значком пошти, доступні в Інтернеті?{#tls} + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + Як Delta Chat захищає метадані у повідомленнях? + + +

+ +

На відміну від більшості інших месенджерів, додатки Delta Chat не зберігають жодних метаданих про контакти чи групи на серверах, навіть у зашифрованому вигляді. Натомість усі метадані груп наскрізно зашифровані та зберігаються виключно на пристроях користувачів.

+ +

Servers can therefore only see:

+ +
    +
  • адреси відправника та одержувача
  • +
  • та розмір повідомлення.
  • +
+ +

За замовчуванням адреси генеруються випадковим чином.

+ +

Усі інші метадані повідомлень, контактів і груп містяться в наскрізно зашифрованій частині повідомлень.

+ +

+ + + Як захистити метадані та контакти якщо пристрій вилучено? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Чи підтримує Delta Chat функцію “Запечатаний відправник”? + + +

+ +

Ні, поки ще ні.

+ +

Месенджер Signal запровадив “Запечатаного відправника” у 2018 році щоб їхня серверна інфраструктура не знала, хто надсилає повідомлення певній групі одержувачів. Це особливо важливо, оскільки сервер Signal знає номер мобільного телефону кожного акаунта, який зазвичай асоціюється з паспортними даними.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Чи підтримує Delta Chat цілковиту пряму секретність (Perfect Forward Secrecy)? + + +

+ +

Ні, поки ще ні.

+ +

Delta Chat наразі не підтримує ідеальну пряму секретність (Perfect Forward Secrecy, PFS). Це означає, що якщо ваш приватний ключ для розшифрування буде скомпрометовано, а хтось заздалегідь зібрав ваші повідомлення під час передачі, він зможе розшифрувати та прочитати їх, використовуючи зламаний ключ. Зверніть увагу, що пряма секретність підвищує рівень безпеки лише в тому разі, якщо ви видаляєте повідомлення. Інакше, якщо хтось отримує доступ до ваших ключів розшифрування, він зазвичай також має доступ до всіх ваших невидалених повідомлень і навіть не потребує розшифровувати заздалегідь перехоплені дані.

+ +

Ми розробили підхід Forward Secrecy, який витримав початкову експертизу від деяких криптографів та експертів з реалізації але чекає на більш офіційний звіт щоб переконатися, що він надійно працює в об’єднаних системах обміну повідомленнями та при використанні декількох пристроїв, перш ніж його можна буде реалізувати в ядрі чату, що зробить його доступним у всіх клієнтах чату.

+ +

+ + + Чи підтримує Delta Chat пост-квантову криптографію? + + +

+ +

Ні, поки ще ні.

+ +

Delta Chat використовує бібліотеку Rust OpenPGP rPGP яка підтримує останню версію IETF Post-Quantum-Cryptography OpenPGP draft. Ми плануємо додати підтримку PQC у chatmail core після того, як проект буде завершено у IETF у співпраці з іншими розробниками OpenPGP.

+ +

+ + + Як я можу вручну перевірити інформацію про шифрування? + + +

+ +

Ви можете перевірити стан наскрізного шифрування вручну в діалоговому вікні “Шифрування” (профіль користувача на Android/iOS або клацніть правою кнопкою миші на елементі списку чату користувача на робочому столі). Delta Chat показує там два відбитки. Якщо на вашому пристрої та пристрої вашого співрозмовника з’являються однакові відбитки, з’єднання безпечне.

+ +

+ + + Чи можна повторно використовувати існуючий закритий ключ? + + +

+ +

Ні.

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Чи проходив Delta Chat незалежний аудит на наявність вразливостей у безпеці? + + +

+ +

Так, багаторазово. Проект Delta Chat постійно проходить незалежні перевірки та аналіз безпеки, а саме, від останніх і закінчуючи старішими:

+ + + +

Проблеми, описані в цих рекомендаціях, виправлено, і вони є частиною випусків Delta Chat у всіх магазинах додатків з грудня 2024 року.

+ +
    +
  • +

    2024 березня ми отримали глибокий аналіз безпеки від дослідницької групи Applied Cryptography дослідницької групи з прикладної криптографії в ETH Zuerich і вирішили всі порушені питання. Більш детальну інформацію можна знайти в нашому блозі в статті Hardening Guaranteed End-to-end encryption, а також в дослідженні Cryptographic Analysis of Delta Chat, опублікованому пізніше.

    +
  • +
  • +

    Починаючи з 2023 року, ми виправили проблеми з безпекою та конфіденційністю у функції “веб застосунків, що поширені у чаті”, пов’язані зі збоями в роботі пісочниці особливо в Chromium. Згодом ми отримали незалежний аудит безпеки аудит безпеки від Cure53, і всі знайдені проблеми були виправлені в серії додатків 1.36, випущених у квітні 2023 року. Повну історію про наскрізну безпеку в Інтернеті дивіться тут.

    +
  • +
  • +

    Починаючи з 2023 року Cure53 проаналізував транспортне шифрування мережевих з’єднань Delta Chat і відтворюване налаштування поштового сервера як рекомендовано на цьому сайті. Ви можете прочитати більше про аудит у нашому блозі або прочитайте повний звіт тут.

    +
  • +
  • +

    У 2020 році Include Security проаналізувала Rust-ядро Delta Chat і бібліотеки IMAP, SMTP та TLS. Він не виявив критичних або серйозних проблем. У звіті виявлено кілька слабких місць середнього ступеня тяжкості – вони самі по собі не становлять загрози для користувачів Delta Chat оскільки вони залежать від середовища, у якому використовується Delta Chat. З міркувань зручності використання та сумісності ми не можемо пом’якшити їх усі тому вирішили надати рекомендації щодо безпеки користувачам, яким загрожує небезпека. Ви можете прочитати повний звіт тут.

    +
  • +
  • +

    У 2019 році Include Security проаналізувала бібліотеки PGP і RSA із Delta Chat. Він не виявив критичних проблем, лише дві серйозні проблеми, які ми згодом виправили. Він також виявив одну проблему середньої тяжкості та кілька менш серйозних, але не було можливості використати ці вразливості в реалізації Delta Chat. Деякі з них ми все ж усунули після завершення аудиту. Ви можете прочитати повний звіт тут.

    +
  • +
+ +

+ + + Інше + + +

+ +

+ + + Яких дозволів потребує Delta Chat? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

Дивіться політику конфіденційності для детального огляду.

+ +

+ + + Де мої друзі можуть знайти Delta Chat? + + +

+ +

Delta Chat доступний для всіх основних і деяких другорядних платформ:

+ +
    +
  • +

    На офіційному сайті, https://delta.chat/download детально описані всі варіанти

    +
  • +
  • +

    Якщо вона недоступна, скористайтеся дзеркалом за адресою https://deltachat.github.io/deltachat-pages

    +
  • +
  • +

    Open one of the following app stores and search for “Delta Chat”: +Google Play Store, F-Droid, Huawei App Gallery, iOS and macOS App Store, Microsoft Store

    +
  • +
  • +

    Перевірте менеджер пакунків вашого дистрибутива Linux

    +
  • +
  • +

    APK для Android також доступні на https://github.com/deltachat/deltachat-android/releases

    +
  • +
+ +

+ + + Як фінансується розробка Delta Chat? + + +

+ +

Delta Chat не отримує жодного Венчурного Капіталу і не є в боргу, не знаходиться під тиском отримання значних прибутків, або продажу користувачів і їхніх друзів з родиною рекламодавцям (або гірше). Ми скоріше використовуємо джерела державного фінансування поки що від ЄС та США, щоб допомогти нашим зусиллям у створенні децентралізованої та різноманітної екосистеми обміну повідомленнями на основі вільного та відкритого коду спільноти розробників

+ +

Конкретно, розробка Delta Chat на сьогодні фінансувалися з наступних джерел, в хронологічному порядку:

+ +
    +
  • +

    Проект ЄС NEXTLEAP фінансував дослідження та впровадження верифікованих груп і протоколів встановлення контактів у 2017 та 2018 роках, а також допоміг інтегрувати наскрізне шифрування через Autocrypt.

    +
  • +
  • +

    Open Technology Fund надав нам два гранти. +Перший грант 2018/2019 року (~$200K), допоміг значно покращили додаток для Android +і випустили першу бета-версію додатка для ПК, і який до того ж +закріпив наші розробки функцій у дослідженнях UX у контексті прав людини, +дивіться наш підсумковий звіт Needfinding and UX report. +Другий грант 2019/2020 року (~$300K) допоміг нам +випустити Delta/iOS версію, конвертувати нашу основному бібліотеку на Rust, +і додати нові функції для всіх платформ.

    +
  • +
  • +

    Фонд NLnet виділив у 2019/2020 роках 46 тисяч євро на +завершення прив’язок Rust/Python та запуск екосистеми чат-ботів.

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    У 2023-2024 роках ми успішно завершили проєкт Secure Chatmail, що фінансувався OTF, що дозволило нам запровадити гарантоване шифрування, створити мережу серверів chatmail та забезпечити “миттєву реєстрацію” у всіх застосунках, випущених з квітня 2024 року.

    +
  • +
  • +

    У 2023 та 2024 роках нас прийняли до програми Next Generation Internet (NGI) за нашу роботу над webxdc PUSH, а також у співпраці з партнерами, які працюють над webxdc evolve, webxdc XMPP, DeltaTouch та DeltaTauri. Усі ці проєкти частково завершені або будуть завершені на початку 2025 року.

    +
  • +
  • +

    Іноді ми отримуємо одноразові пожертви від приватних осіб. Наприклад, у 2021 році щедра приватна особа перерахував нам 4 тис. євро з повідомленням «так тримати!». 💜 Ми використовуємо такі пожертви для фінансування зборів на розвиток або для тимчасових витрат, які важко передбачити або відшкодувати за рахунок грантів державного фінансування. Отримання більшої кількості пожертв також допомагає нам стати більш незалежними та довгостроково життєздатними як спільнота контриб’юторів.

    + + +
  • +
  • +

    Кілька експертів та ентузіастів, які працюють на громадських засадах, сприяли розробці програми Delta Chat, не отримуючи грошей або лише невеликі суми. Без них Delta Chat не був би там, де є сьогодні, навіть близько.

    +
  • +
+ +

Зазначене вище грошове фінансування в основному організовано компанією merlinux GmbH у Фрайбурзі (Німеччина) і розподілено між більше ніж дюжиною розробників по всьому світу.

+ +

Перегляньте канали контрибуції Delta Chat як для грошових, так і для інших можливостей допомоги.

+ + + + + \ No newline at end of file diff --git a/src/main/assets/help/zh_CN/help.html b/src/main/assets/help/zh_CN/help.html new file mode 100644 index 0000000000000000000000000000000000000000..b70b0e936e6ab70e23b34e8699659e657a04afb0 --- /dev/null +++ b/src/main/assets/help/zh_CN/help.html @@ -0,0 +1,1610 @@ + + + + + +

+ + + 什么是 Delta Chat? + + +

+ +

Delta Chat is a reliable, decentralized and secure instant messaging app, +available for mobile and desktop platforms.

+ + + +

+ + + How can I find people to chat with? + + +

+ +

First, note that Delta Chat is a private messenger. +There is no public discovery, you decide about your contacts.

+ +
    +
  • +

    If you are face to face with your friend or family, +tap the QR Code icon +on the main screen.
    +Ask your chat partner to scan the QR image +with their Delta Chat app.

    +
  • +
  • +

    For a remote contact setup, +from the same screen, +click “Copy” or “Share” and send the invite link +through another private chat.

    +
  • +
+ +

Now wait while connection gets established.

+ +
    +
  • +

    If both sides are online, they will soon see a chat +and can start messaging securely.

    +
  • +
  • +

    If one side is offline or in bad network, +the ability to chat is delayed until connectivity is restored.

    +
  • +
+ +

Congratulations! +You now will automatically use end-to-end encryption with this contact. +If you add each other to groups, end-to-end encryption will be established among all members.

+ +

+ + + Why is a chat marked as “Request”? + + +

+ +

As being a private messenger, +only friends and family you share your QR code or invite link with can write to you.

+ +

Your friends may share your contact with other friends, this appears as a request.

+ +
    +
  • +

    需先通过该验证请求,用户方可发送回复。

    +
  • +
  • +

    若你暂无交流意愿,可直接删除该请求以终止对话。

    +
  • +
  • +

    若您选择删除某条消息请求,对方后续发来的消息仍会以「消息请求」形式显示, +以便您保留重新考虑的机会。若需彻底拒收该联系人消息, +建议直接启用 ​Block 功能进行屏蔽。

    +
  • +
+ +

+ + + How can I put two of my friends in contact with each other? + + +

+ +

Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. +You can also add a little introduction message.

+ +

The second contact will receive a card then +and can tap it to start chatting with the first contact.

+ +

+ + + Delta Chat 支持图像、视频和其他附件吗? + + +

+ +
    +
  • +

    是的。 Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- +or Microphone Voice Message buttons

    +
  • +
  • +

    为了提高性能,默认情况下会对图像进行优化并以较小的尺寸发送,但您也可以将其作为 “文件 “发送,以保留原始图像。

    +
  • +
+ +

+ + + 什么是账户资料?如何在它们之间切换? + + +

+ +

A profile is a name, a picture and some additional information for encrypting messages. +A profile lives on your device(s) only +and uses the server only to relay messages.

+ +

首次安装Delta Chat 时,会创建第一个账户资料文件。

+ +

之后,您可以点击左上角的个人资料图像,添加个人账户 +或切换账户

+ +

You may want to use separate profiles for political, family or work related activities.

+ +

您可能还想了解 如何在多台设备上使用同一账户资料

+ +

+ + + 谁会看见我的个人资料图片? + + +

+ +
    +
  • +

    您可以在设置中添加个人资料图片。如果您给您的联系人发消息或者通过二维码添加他们,他们会自动看到您的个人资料图片。

    +
  • +
  • +

    出于隐私原因,在您向他们发送消息之前,没有人会看到您的个人资料照片。

    +
  • +
+ +

+ + + Can I set a Bio/Status with Delta Chat? + + +

+ +

Yes, +you can do so under Settings → Profile → Bio. +Once you sent a message to a contact, +they will see it when they view your contact details.

+ +

+ + + 固定、静音、归档是什么意思? + + +

+ +

使用这些工具来管理您的聊天,让其井然有序:

+ +
    +
  • +

    已固定聊天会呆在聊天列表顶部。您可以利用其快速访问最喜欢的聊天或临时记下某些东西。

    +
  • +
  • +

    静音聊天,如果您不想再得到关于它们的通知。被静音的聊天会呆在原地,并且您可以固定被静音的聊天。

    +
  • +
  • +

    如果您不想再在聊天列表中看到聊天记录,请归档聊天。 +已归档的聊天仍可在聊天列表上方或通过搜索访问。

    +
  • +
  • +

    当被归档的聊天接收到一条新消息,除非其被静音,它会从归档中弹出并返回聊天列表。 +被静音的聊天会保持被归档的状态,除非您手动解档它们。

    +
  • +
+ +

要归档或固定一个聊天,可以长按(Android)、使用聊天内部的菜单(Android/桌面版)或者左滑(iOS); +要静音一个聊天,可以使用聊天内部的菜单(Android/桌面版)或者通过聊天概要(iOS)。

+ +

+ + + “保存的消息”如何工作? + + +

+ +

Saved Messages 是专用于存储消息的专属对话窗口,可帮助您便捷存储和快速检索重要信息。

+ +
    +
  • +

    在任何聊天中,长按或右键单击消息并选择 ** 保存 **

    +
  • +
  • +

    已保存的消息会显示专属标识符号。 +Saved icon +在时间戳右侧

    +
  • +
  • +

    后续操作时,进入「Saved Messages」对话窗口即可查看所有已保存内容。 +轻触 Arrow-right icon 按钮, +系统将带您快速跳转回原始聊天中的对应消息。

    +
  • +
  • +

    您还可以将「Save Messages」作为个人记事本使用:进入该对话窗口后,既可编辑文字内容,也能添加图片、语音录音等多媒体素材。

    +
  • +
  • +

    “Saved Message” 的同步特性使其成为设备间数据迁移的实用方案——用户通过该功能可轻松实现手机、电脑等多终端信息流转。

    +
  • +
+ +

即使消息遭到修改或删除 - 无论是发送者编辑设备清理操作,还是其他聊天中的临时消息功能所触发 - 这些信息仍将保留在系统中。

+ +

+ + + 绿色圆点代表什么? + + +

+ +

You can sometimes see a green dot +next to the avatar of a contact. +It means they were recently seen by you in the last 10 minutes, +e.g. because they messaged you or sent a read receipt.

+ +

So this is not a real time online status +and others will as well not always see that you are “online”.

+ +

+ + + 显示在发出消息旁边的对勾表示什么? + + +

+ +
    +
  • +

    One tick +means that the message was sent successfully to your provider.

    +
  • +
  • +

    Two ticks +mean that at least one recipient’s device +reported back to having received the message.

    +
  • +
  • +

    Recipients may have disabled read-receipts, +so even if you see only one tick, the message may have been read.

    +
  • +
  • +

    The other way round, two ticks do not automatically mean +that a human has read or understood the message ;)

    +
  • +
+ +

+ + + 发送后更正错别字并删除邮件 + + +

+ +
    +
  • +

    已发送消息支持文本内容修改。操作时,用户只需长按(移动端)或右键单击(电脑端)目标消息,调出功能菜单后点选EditEdit icon即可进入编辑模式。

    +
  • +
  • +

    若发生消息误发情况, +可通过以下路径撤回:在当前操作菜单中,依次选择删除为所有人删除选项。

    +
  • +
+ +

在聊天场景中,经过编辑的消息会在时间戳旁标注“Edited”提示, +而被删除的信息则会彻底消失且不显示任何痕迹。 +相关操作既不会触发系统通知,也没有规定必须在时限内完成修改。

+ +

需特别提示:若聊天成员已对消息进行过回复、转发、保存本地、截图留存或其他形式的复制操作, +即使您后续编辑了原始消息,对方设备上仍可能留存原内容。

+ +

+ + + 消息定时销毁是如何工作的? + + +

+ +

You can turn on “disappearing messages” +in the settings of a chat, +at the top right of the chat window, +by selecting a time span +between 5 minutes and 1 year.

+ +

Until the setting is turned off again, +each chat member’s Delta Chat app takes care +of deleting the messages +after the selected time span. +The time span begins +when the receiver first sees the message in Delta Chat. +The messages are deleted both, +on the servers, +and in the apps itself.

+ +

请注意,只有当您信任您的聊天伙伴时,您才可以依赖“消息定时销毁”; +不怀好意的人可能会拍照,或者在删除之前以其他方式保存、复制或转发消息。

+ +

Apart from that, +if one chat partner uninstalls Delta Chat, +the (anyway encrypted) messages may take longer to get deleted from their server.

+ +

+ + + 打开“从设备删除旧消息”后,会发生什么? + + +

+ +
    +
  • 若要节省设备上的存储空间,可以开启自动删除旧消息。
  • +
  • 找到“聊天与媒体”设置中的“从设备删除旧消息”,在从“一小时后”到“一年后”的一系列选项中选择一个。这样,设备上 所有 比所选择时间长度老的消息将被删除。
  • +
+ +

+ + + How can I delete my chat profile? + + +

+ +

If you are using more than one chat profile, +you can remove single ones in the top profile switcher menu (on Android and iOS), +or in the sidebar with a right click (in the Desktop app). +Chat profiles are only removed on the device where deletion was triggered. +Chat profiles on other devices will continue to fully function.

+ +

If you use a single default chat profile you can simply uninstall the app. +This will still automatically trigger deletion of all associated address data on the chatmail server. +For more info, please refer to nine.testrun.org address-deletion +or the respective page from your chosen 3rd party chatmail server.

+ +

+ + + Groups + + +

+ +

Groups let several people chat together privately with equal rights.

+ +

Anyone can +change the group name or avatar, +add or remove members, +set disappearing messages, +and delete their own messages from all member’s devices.

+ +

Because all members have the same rights, groups work best among trusted friends and family.

+ +

+ + + 创建群组 + + +

+ +
    +
  • 从右上角的菜单中选择新建聊天,然后选择新建群组或在 Android/iOS 上点击相应的按钮。
  • +
  • 在随后的屏幕上,选择群组成员并起一个群组名称。您也可以选择一个群组头像
  • +
  • 当您在群组中发送第一条消息时,所有成员都会被告知新群组的信息并可以在该群组中应答(只要您不在群组中发送第一条消息,那么群组对成员就是不可见的)。
  • +
+ +

+ + + Add and remove members + + +

+ +
    +
  • +

    All group members have the same rights. +For this reason, everyone can delete any member or add new ones.

    +
  • +
  • +

    To add or delete members, tap the group name in the chat and select the member to add or remove.

    +
  • +
  • +

    If the member is not yet in your contact list, but face to face with you, +from the same screen, show a QR code.
    +Ask your chat partner to scan the QR image with their Delta Chat app by tapping + on the main screen.

    +
  • +
  • +

    For a remote member addition, +click “Copy” or “Share” and send the invite link +through another private chat to the new member.

    +
  • +
+ +

QR code and invite link can be used to add several members. +However, since groups are meant for trusted people, avoid sharing them publicly.

+ +

+ + + 我不小心删除了我自己。 + + +

+ +
    +
  • 由于您不再是群组成员,您无法将自己加入到群组中。但是,问题不大,只需在普通聊天中请求其他群组成员将您重新加入即可。
  • +
+ +

+ + + 我不想再收到某个群组中的消息了。 + + +

+ +
    +
  • +

    从成员列表中删除自己,或者删除整个聊天。如果您之后想再加入该群组,请让其他群组成员添加您。

    +
  • +
  • +

    另外,您也可以“静音”群组——这样做意味着您会收到所有消息并且仍可以编写消息,但不会再收到任何新消息的通知。

    +
  • +
+ +

+ + + Cloning a group + + +

+ +

You can duplicate a group to start a separate discussion +or to exclude members without them noticing.

+ +
    +
  • +

    Open the group profile and tap Clone Chat (Android/iOS), +or right-click the group in the chat list (Desktop).

    +
  • +
  • +

    Set a new name, choose an avatar, and adjust the member list if needed.

    +
  • +
+ +

The new group is fully independent from the original, +which continues to work as before.

+ +

+ + + In-chat apps + + +

+ +

You can send apps to a chat - games, editors, polls and other tools. +This makes Delta Chat a truly extensible messenger.

+ +

+ + + Where can I get in-chat apps? + + +

+ +
    +
  • +

    In a chat, using Paperclip Attachment Button → Apps

    +
  • +
  • +

    You can also create your own app and attach it using Paperclip Attachment Button → File

    +
  • +
+ +

+ + + How private are in-chat apps? + + +

+ +
    +
  • +

    In-chat apps can not send data to the Internet, or download anything.

    +
  • +
  • +

    An in-chat app can only exchange data within a chat, with its +copies on the devices of your chat partners. Other than that, it’s completely +isolated from the Internet.

    +
  • +
  • +

    The privacy an in-chat app offers is the privacy of your chat - as long as you +trust the people you chat with, you can trust the in-chat app as well.

    +
  • +
  • +

    This also means: Just like for web links, do not open apps from untrusted contacts.

    +
  • +
+ +

+ + + How can I create my own in-chat apps? + + +

+ +
    +
  • +

    In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

    +
  • +
  • +

    You can extend the Hello World example app +to get started.

    +
  • +
  • +

    All else you need to know is written in the +Webxdc documentation.

    +
  • +
  • +

    If you have question, you can ask others with experience +in the Delta Chat Forum.

    +
  • +
+ +

+ + + 即时消息传递和推送通知 + + +

+ +

+ + + 什么是推送通知?如何获得即时消息传递? + + +

+ +

推送通知由 Apple 和 Google 的“推送服务”发送到用户的设备,以便非活动状态的 Delta Chat 应用可以在后台获取消息,并在需要时在用户的手机上显示通知。

+ +

推送通知适用于以下所有 chatmail 服务器:

+ +
    +
  • +

    iOS 设备通过与 Apple Push 服务集成。

    +
  • +
  • +

    Android 设备,通过与 Google FCM Push 服务集成, +包括使用 microG +而不是手机上专有 Google 代码的设备。

    +
  • +
+ +

+ + + iOS 设备上是否启用了推送通知?我还有其他的选择吗? + + +

+ +

是的,Delta Chat 会自动使用推送通知来接收 chatmail 个人资料。 +而且,Apple 手机上没有其他方式可以实现即时消息传递 +因为 Apple 设备不允许 Delta Chat 在后台获取数据。 +推送通知会自动为 iOS 用户激活,因为 +Delta Chat 的隐私保护推送通知系统 +不会向 Apple 泄露其尚未拥有的数据。

+ +

+ + + Android 设备上是否启用/需要推送通知? + + +

+ +

If a “Push Service” is available, Delta Chat enables Push Notifications +to achieve instant message delivery for all chatmail users.

+ +

在 Delta Chat“通知”的“推送通知”设置中,您可以更改以下影响所有聊天配置文件的设置:

+ +
    +
  • +

    使用后台连接:如果你没有使用推送服务, +你可以禁用 Delta Chat 的“电池优化”, +允许它在后台获取消息。 +但是,可能会有几分钟到几小时的延迟。 +一些 Android 供应商甚至完全限制应用 +(请参阅 dontkillmyapp.com), +并且 Delta Chat 可能不会显示传入的消息, +直到你手动再次打开应用为止。

    +
  • +
  • +

    强制后台连接:如果之前的选项不可用或无法实现“即时传递”, +这是后备选项。 +启用它会在你的手机上导致永久通知, +这有时可能会被最新的 Android 手机“最小化”。

    +
  • +
+ +

如果消息到达时间延迟较长, +则“后台连接”选项都节能且安全,可以尝试。

+ +

+ + + Delta Chat 推送通知的隐私性如何? + + +

+ +

Delta Chat Push Notification support avoids leakage of private information. +It does not leak profile data, IP address or message content (not even encrypted) +to any system involved in the delivery of Push Notifications.

+ +

以下是 Delta Chat 应用如何执行推送通知传递:

+ +
    +
  • +

    Delta Chat 应用在本地获取“设备令牌”,对其进行加密并将其存储在 +Chatmail 服务器上。

    +
  • +
  • +

    When a chatmail server receives a message for a Delta Chat user +it forwards the encrypted device token to the central Delta Chat notification proxy.

    +
  • +
  • +

    The central Delta Chat notification proxy decrypts the device token +and forwards it to the respective Push service (Apple, Google, etc.), +without ever knowing the IP or profile data of Delta Chat users.

    +
  • +
  • +

    The central Push Service (Apple, Google, etc.) +wakes up the Delta Chat app on your device +to check for new messages in the background. +It does not know about the profile data of the device it wakes up. +The central Apple/Google Push services never see any profile data (sender or receiver) +and also never see any message content (also not in encrypted forms).

    +
  • +
+ +

中央 Delta Chat 通知代理体积小,完全用 Rust 实现 +,并在 Apple/Google 等处理设备令牌后立即忘记它们, +通常在几毫秒内。

+ +

Note that the device token is encrypted between apps and notification proxy +but it is not signed. +The notification proxy thus never sees profile data, IP-addresses or +any cryptographic identity information associated with a user’s device (token).

+ +

由此产生的整体隐私设计,即使查封 Chatmail 服务器, +或完全查封中央 Delta Chat 通知代理 +也不会泄露推送服务尚未拥有的私人信息。

+ +

+ + + 为什么 Delta Chat 与集中式专有的 Apple/Google 推送服务集成? + + +

+ +

Delta Chat 是一款免费且开源的去中心化即时通讯应用,用户可以自由选择服务器, +但我们希望用户可靠地体验到“即时消息传递”, +就像他们从 Whatsapp、Signal 或 Telegram 应用体验到的那样, +而无需预先提出更适合专家用户或开发人员的问题。

+ +

Note that Delta Chat has a small and privacy-preserving Push Notification system +that achieves “instant delivery” of messages for all chatmail servers +including a potential one you might setup yourself without our permission. +Welcome to the power of the interoperable chatmail relay network :)

+ +

+ + + 多客户端 + + +

+ +

+ + + 我能同时在多个设备上使用 Delta Chat 吗? + + +

+ +

是的,您可以在不同设备上使用相同的配置文件:

+ +
    +
  • +

    确保两台设备都在同一个 Wi-Fi 或网络中

    +
  • +
  • +

    在第一台设备上,转到设置 → 添加第二台设备,如果需要,解锁屏幕 +并稍等片刻,直到显示二维码

    +
  • +
  • +

    在第二台设备上,安装 Delta Chat

    +
  • +
  • +

    在第二台设备上,启动 Delta Chat,选择添加为第二台设备,然后扫描旧设备上的二维码

    +
  • +
  • +

    传输应在几秒钟后开始,并且在传输过程中,两台设备都将显示进度。 +等待直到两台设备都完成。

    +
  • +
+ +

与其他许多即时通讯应用不同,在成功传输后, +两台设备完全独立。 +一台设备不是另一台设备工作的必要条件。

+ +

+ + + 故障排除 + + +

+ +
    +
  • +

    仔细检查两台设备是否在同一个 Wi-Fi 或网络中

    +
  • +
  • +

    Windows 上,转到控制面板 / 网络和 Internet +并确保专用网络被选为“网络配置文件类型” +(传输后,你可以更改回原始值)

    +
  • +
  • +

    iOS 上,确保授予“系统设置 / 应用 / Delta Chat / 本地网络”访问权限

    +
  • +
  • +

    macOS 上,启用“系统设置 / 隐私和安全 / 本地网络 / Delta Chat”

    +
  • +
  • +

    你的系统可能具有“个人防火墙”, +已知这会引起问题(尤其是在 Windows 上)。 +在两端禁用个人防火墙以用于 Delta Chat,然后重试

    +
  • +
  • +

    访客网络可能不允许设备相互通信。 +如果可能,请使用非访客网络。

    +
  • +
  • +

    当设备间网络通信持续异常时, +建议采取设备直连方案:在一台终端开启 ​Mobile Hotspot​(移动热点),另一台设备通过扫描 Wi-Fi 接入该临时网络。

    +
  • +
  • +

    确保目标设备上有足够的存储空间

    +
  • +
  • +

    如果传输已开始,请确保设备保持活动状态,并且不会进入睡眠状态。 +不要退出 Delta Chat。 +(我们努力使应用在后台工作,但不幸的是,系统倾向于杀死应用

    +
  • +
  • +

    目标设备上已登录 Delta Chat? +你可以在每台设备上使用多个配置文件,只需添加另一个配置文件

    +
  • +
  • +

    如果你仍然遇到问题,或者无法扫描二维码 +,请尝试下面描述的手动传输

    +
  • +
+ +

+ + + 手动传输 + + +

+ +

仅当上述“添加第二台设备”方法不起作用时,才建议使用此方法。

+ +
    +
  • 在旧设备上,转到“设置 -> 聊天和媒体 -> 导出备份”。 输入你的 +屏幕解锁 PIN 码、图案或密码。 然后你可以点击“开始 +备份”。 这会将备份文件保存到你的设备。 现在你必须 +以某种方式将其传输到另一台设备。
  • +
  • 在新设备上,在“我已经有一个配置文件”菜单中, +选择“从备份还原”。 导入后,你的对话、加密 +密钥和媒体应复制到新设备。
  • +
  • 如果你使用 iOS: 并且你遇到困难,也许 +本指南 将 +帮助你。
  • +
  • 你现在已同步,并且可以使用两台设备与你的通信伙伴发送和接收 +端到端加密消息。
  • +
+ +

+ + + 有推出 Delta Chat Web 客户端的计划吗? + + +

+ +
    +
  • 目前没有计划,但有一些初步的想法。
  • +
  • 有 2-3 种途径来实现 Delta Chat Web 客户端,但是它们都需要巨大的工作量。目前,我们专注于将稳定的版本作为本地应用程序发布到所有应用程序商店(Google Play/iOS/Windows/macOS/Linux 仓库)。
  • +
  • 如果是因为不能在工作的电脑上安装软件而需要一个 Web 客户端,您可以使用便携版的 Windows 桌面客户端,或者在 Linux 上使用 AppImage 版。您可以在 get.delta.chat 找到它们。
  • +
+ +

+ + + Advanced + + +

+ +

+ + + Experimental Features + + +

+ +

At Settings → Advanced → Experimental Features +you can try out features we are working on.

+ +

The features may be unstable and may be changed or removed.

+ +

You can find more information +and give feedback in the Forum.

+ +

+ + + What is “Send statistics to Delta Chat’s developers”? + + +

+ +

We would like to improve Delta Chat with your help, +which is why Delta Chat for Android asks whether you want +to send anonymous usage statistics.

+ +

You can turn it on and off at +Settings → Advanced → Send statistics to Delta Chat’s developers.

+ +

When you turn it on, +weekly statistics will be automatically sent to a bot.

+ +

We are interested e.g. in statistics like:

+ +
    +
  • How many contacts are introduced by personally scanning a QR code?
  • +
  • Which versions of Delta Chat are being used?
  • +
  • How many messages are unencrypted?
  • +
+ +

We will not collect any personally identifiable information about you.

+ +

+ + + Can I use a classic email address with Delta Chat? + + +

+ +

Yes, but only if the email address is used exclusively by chatmail clients.

+ +

It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, +for the following reasons:

+ +
    +
  • +

    Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, +while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

    +
  • +
  • +

    Non-chatmail apps use email servers as a long-term message archive +while chatmail clients use email servers for ephemeral instant message relay.

    +
  • +
  • +

    Supporting the full variety of classic email setups +would require considerable development and maintenance efforts, +and complicate making chatmail-based messaging more resilient, reliable and fast.

    +
  • +
+ +

+ + + How can I configure a chat profile with a classic email address as relay? + + +

+ +

First off, please do not use the same classic email address also from non-chatmail classic email apps +unless you are prepared to deal with encrypted messages in the inbox, +double notifications, accidentally deleted emails or similar annoyances.

+ +

You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. +Note that classic email providers will generally not support Push Notifications +and have other limitations, see Provider Overview. +Chatmail uses the default INBOX for relay; ensure the provider setup does too. +A chat profile using a classic email address allows to to send and receive unencrypted messages. +These messages, and the chats they appear in, are marked with an email icon +email.

+ +

+ + + I want to manage my own server for Delta Chat. What do you recommend? + + +

+ +

Any well behaving email server setup will do fine +except if your users’ devices require Google/Apple Push Notifications to work properly.

+ +

We generally recommend to set up a chatmail relay. +Chatmail is a community-driven project that encompasses both the setup of relays +and core Rust developments +that power chatmail clients of which Delta Chat is the most well known.

+ +

+ + + 我对技术细节很感兴趣。能告诉我更多吗? + + +

+ + + +

+ + + 加密和安全 + + +

+ +

+ + + 端到端加密使用了哪些标准 ? + + +

+ +

Delta Chat 使用 OpenPGP 标准的安全子集 +使用以下协议提供自动端到端加密:

+ +
    +
  • +

    安全加入 +通过二维码扫描或“邀请链接”交换加密设置信息。

    +
  • +
  • +

    Autocrypt is used for automatically +用于在联系人和群聊的所有成员之间自动建立端到端加密。

    +
  • +
  • +

    将联系人分享到聊天中 + +接收者可以与该联系人使用端到端加密。

    +
  • +
+ +

Delta Chat 不会查询、发布或与任何 OpenPGP 密钥服务器交互。

+ +

+ + + How can I know if messages are end-to-end encrypted? + + +

+ +

Delta Chat 中的所有消息 默认都采用端到端加密。 +自 Delta Chat 版本 2 发布系列(2025 年 7 月)起, +端到端加密消息上不再有锁或类似的标记。

+ +

+ + + Can I still receive or send messages without end-to-end encryption? + + +

+ +

如果您使用默认的 chatmail 中继, +则不可能在没有端到端加密的情况下接收或发送消息。

+ +

If you instead use a classic email server, +you can send and receive messages with or without end-to-end encryption. +Messages lacking end-to-end encryption are marked with an email icon +email.

+ +

+ + + What does the green checkmark in a contact profile mean? + + +

+ +

A contact profile might show a green checkmark +green checkmark +and an “Introduced by” line. +Every green-checkmarked contact either did a direct QR-scan with you +or was introduced by a another green-checkmarked contact. +Introductions happen automatically when adding members to groups. +Whoever adds a green-checkmarked contact to a group with only green-checkmarked members +becomes an introducer. +In a contact profile you can tap on the “Introduced by …” text repeatedly +until you get to the one with whom you directly did a QR-scan.

+ +

有关“保证的端到端加密”的更深入讨论, +请参阅 安全加入协议, +并专门阅读有关“已验证群组”的内容,这是 +此处所谓的“带有绿色复选标记”或“保证的端到端加密”聊天的技术术语。

+ +

+ + + 附件(图片、文件、音频等)是否已端到端加密? + + +

+ +

是的。

+ +

当我们谈论“端到端加密消息”时, +我们始终指的是整个消息都已加密, +包括所有附件和附件元数据,例如文件名。

+ +

+ + + OpenPGP 安全吗? + + +

+ +

Yes, Delta Chat uses a secure subset of OpenPGP +requiring the whole message to be properly encrypted and signed. +For example, “Detached signatures” are not treated as secure.

+ +

OpenPGP 加密标准本身不存在安全隐患。 +目前公众讨论中涉及的 OpenPGP 安全问题, +大多源自相关工具或应用的用户体验缺陷或技术实现漏洞(或二者叠加)。 +需特别澄清的是:OpenPGP 作为 IETF 制定的加密标准, +与基于命令行操作的 GnuPG(GPG)工具不可混为一谈。 +诸多对 OpenPGP 的质疑实际指向 GnuPG 工具,而 Delta Chat 即时通讯应用从未采用该工具。 +Delta Chat 实际使用的是 Rust 语言编写的 OpenPGP 实现库 rPGP, +该库以独立 “pgp” 组件包形式提供, +且已通过 2019 和 2024 年两次安全审计

+ +

我们的目标是与其他 OpenPGP 实现者一起, +通过实施 新的 IETF OpenPGP Crypto-Refresh +来进一步提高安全特性,该标准已于 2023 年夏季获得通过,令人欣慰。

+ +

+ + + Did you consider using alternatives to OpenPGP for end-to-end-encryption? + + +

+ +

Yes, we are following efforts like MLS +but adopting them would mean breaking end-to-end encryption interoperability. +So it would not be a light decision to take +and there must be tangible improvements for users.

+ +

Delta Chat 采用整体“可用安全性”方法, +并与广泛的活动家团体以及 +TeamUSEC 等知名研究人员合作 +,以改进针对安全威胁的实际用户结果。 +用于建立端到端加密的线路协议和标准 +只是“用户结果”的一部分, +另请参阅我们对 设备查封 +和 消息元数据 问题的回答。

+ +

+ + + Delta Chat 是否容易受到 EFAIL 攻击? + + +

+ +

不,Delta Chat 从未受到任何 EFAIL 攻击 +因为所使用的 OpenPGP 实现了 rPGP +在加密消息时“修改检测代码” +并且如果“修改检测代码”不正确则返回 错误

+ +

Delta Chat 也从未容易受到“直接泄露”EFAIL 攻击, +因为它只解密 multipart/encrypted 消息, +这些消息正好包含一个加密和签名的部分, +如 Autocrypt Level 1 规范所定义。

+ +

+ + + Are messages marked with the mail icon exposed on the Internet? + + +

+ +

If you are sending or receiving email messages without end-to-end encryption (using a classic email server), +they are still protected from cell or cable companies who can not read or modify your email messages. +But both your and your recipient’s email providers +may read, analyze or modify your messages, including any attachments.

+ +

Delta Chat by default uses strict +TLS encryption +which secures connections between your device and your email provider. +All of Delta Chat’s TLS-handling has been independently security audited. +Moreover, the connection between your and the recipient’s email provider +will typically be transport-encrypted as well. +If the involved email servers support MTA-STS +then transport encryption will be enforced between email providers +in which case Delta Chat communications will never be exposed in cleartext to the Internet +even if the message was not end-to-end encrypted.

+ +

+ + + Delta Chat 如何保护消息中的元数据? + + +

+ +

Unlike most other messengers, +Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. +Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

+ +

Servers can therefore only see:

+ +
    +
  • the sender and receiver addresses
  • +
  • and the message size.
  • +
+ +

By default, the addresses are randomly generated.

+ +

All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

+ +

+ + + 当设备被查封时,如何保护元数据和联系人? + + +

+ +

Both for protecting against metadata-collecting servers +as well as against the threat of device seizure +we recommend to use a chatmail relay +to create chat profiles using random addresses for transport. +Note that Delta Chat apps on all platforms support multiple profiles +so you can easily use situation-specific profiles next to your “main” profile +with the knowledge that all their data, along with all metadata, will be deleted. +Moreover, if a device is seized then chat contacts using short-lived profiles +can not be identified easily.

+ +

+ + + Does Delta Chat support “Sealed Sender”? + + +

+ +

No, not yet.

+ +

The Signal messenger introduced “Sealed Sender” in 2018 +to keep their server infrastructure ignorant of who is sending a message to a set of recipients. +It is particularly important because the Signal server knows the mobile number of each account, +which is usually associated with a passport identity.

+ +

Even if chatmail relays +do not ask for any private data (including no phone numbers), +it might still be worthwhile to protect relational metadata between addresses. +We don’t foresee bigger problems in using random throw-away addresses for sealed sending +but an implementation has not been agreed as a priority yet.

+ +

+ + + Delta Chat 是否支持完美前向保密? + + +

+ +

No, not yet.

+ +

Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). +This means that if your private decryption key is leaked, +and someone has collected your prior in-transit messages, +they will be able to decrypt and read them using the leaked decryption key. +Note that Forward Secrecy only increases security if you delete messages. +Otherwise, someone obtaining your decryption keys +is typically also able to get all your non-deleted messages +and doesn’t even need to decrypt any previously collected messages.

+ +

We designed a Forward Secrecy approach that withstood +initial examination from some cryptographers and implementation experts +but is pending a more formal write up +to ascertain it reliably works in federated messaging and with multi-device usage, +before it could be implemented in chatmail core, +which would make it available in all chatmail clients.

+ +

+ + + Does Delta Chat support Post-Quantum-Cryptography? + + +

+ +

No, not yet.

+ +

Delta Chat uses the Rust OpenPGP library rPGP +which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. +We aim to add PQC support in chatmail core after the draft is finalized at the IETF +in collaboration with other OpenPGP implementers.

+ +

+ + + How can I manually check encryption information? + + +

+ +

你可以在“加密”对话框中手动检查端到端加密状态 +(Android/iOS 上的用户配置文件或桌面上的用户聊天列表项上右键单击)。 +Delta Chat 在此处显示两个指纹。 +如果相同的指纹出现在你自己的设备和你联系人的设备上, +则连接是安全的。

+ +

+ + + 我可以重复使用现有的私钥吗? + + +

+ +

不。

+ +

Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. +We do not recommend or offer users to perform manual key management. +We want to ensure that security audits can focus on a few proven cryptographic algorithms +instead of the full breadth of possible algorithms allowed with OpenPGP. +If you want to extract your OpenPGP key, there only is an expert method: +you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

+ +

+ + + Delta Chat 是否已进行独立的安全漏洞审计? + + +

+ +

确实如此,且已进行多次。 +Delta Chat 长期接受第三方独立机构的安全审计与漏洞分析, +以下按时间倒序列出历年检测记录:

+ + + +

自 2024 年 12 月之后,各应用商店发布的 Delta Chat 版本均已修复上述安全公告提及的问题。 +相关更新作为常规版本升级的一部分,现已全面覆盖所有 appstore 平台。

+ +
    +
  • +

    2024 年 3 月,我们收到了苏黎世联邦理工学院应用密码学 +研究小组的深入安全性分析,并解决了所有提出的问题。 +有关更多详细信息,请参阅我们关于 加强保证的端到端加密 的博客文章以及 +之后发表的 Delta Chat 密码学分析 +研究论文。

    +
  • +
  • +

    2023 年 4 月,我们修复了“在聊天中共享的 Web 应用”的安全性 +和隐私问题,这些问题与沙箱故障有关,尤其是在 Chromium 中。 随后,我们获得了 Cure53 的独立安全 +审计,并且在 2023 年 4 月发布的 1.36 应用系列中修复了发现的所有问题。 +请参阅 此处,了解有关 Web 中端到端安全性的完整背景故事

    +
  • +
  • +

    2023 年 3 月,Cure53 分析了 Delta Chat 网络连接的传输加密和一个可重现的邮件服务器设置,如 +本网站 推荐的那样。 +你可以在 我们的博客 上阅读有关审计的更多信息 +,或在此处阅读 完整报告

    +
  • +
  • +

    2020 年,Include Security 分析了 Delta Chat 的 Rust 核心、 +IMAP、 +SMTP 和 +TLS 库。 +它没有发现任何严重或高严重性问题。 +该报告提出了一些中等严重性的弱点 - +它们本身不会对 Delta Chat 用户构成威胁, +因为它们取决于 Delta Chat 使用的环境。 +出于可用性和兼容性原因, +我们无法减轻所有这些弱点, +并决定向受威胁的用户提供安全建议。 +你可以在 此处阅读完整报告

    +
  • +
  • +

    2019 年,Include Security 分析了 Delta +Chat 的 PGP 和 +RSA 库。 +它没有发现任何严重问题, +但发现了两个高严重性问题,我们随后修复了这些问题。 +它还揭示了一个中等严重性和一些不太严重的问题, +但在 Delta Chat 实现中无法利用这些漏洞。 +尽管如此,自审计结束以来,我们仍然修复了其中一些问题。 +你可以在 此处阅读完整报告

    +
  • +
+ +

+ + + 杂项 + + +

+ +

+ + + Delta Chat 需要哪些权限? + + +

+ +

Some features require certain permissions, +e.g. you need to grant camera permission if you want to scan an invite QR code.

+ +

See Privacy Policy for a detailed overview.

+ +

+ + + 我的朋友在哪里可以找到 Delta Chat? + + +

+ +

Delta Chat 适用于所有主要平台和一些次要平台:

+ + + +

+ + + Delta Chat 的开发是如何被资助的? + + +

+ +

Delta Chat 没有接受风险投资,也没有负债累累,更没有承受产生巨额利润或将用户及其朋友和家人卖给广告商(或更糟)的压力。我们宁愿使用目前来自欧盟和美国的公共资金,来帮助我们努力建立一个基于自由开源社区开发的、去中心化的、多样化的聊天消息软件生态系统。

+ +

具体而言,Delta Chat 的开发迄今为止已从以下来源获得资金, +按时间顺序排列:

+ +
    +
  • +

    NEXTLEAP欧盟项目资助了以下研究和实施工作:在 2017 年和 2018 年实施的验证组和设置联系协议和通过 Autocrypt整合了端到端加密。

    +
  • +
  • +

    开放技术基金 2018/2019 年提供的第一笔赠款(约 20 万美元)期间,我们显著改善了安卓应用,发布了第一个桌面测试版,并根据人权方面的用户体验研究进行了功能开发,请参阅我们的结论《需求发现与用户体验报告》。2019/2020 年的第二笔赠款(约 30 万美元)对发布 Delta/iOS 版本,将核心库转换到 Rust ,以及为所有平台开发新功能提供了帮助。

    +
  • +
  • +

    NLnet 基金会 2019/2020 年拨款 4.6 万欧元,用于完成 Rust/Python 绑定并建立聊天机器人生态系统。

    +
  • +
  • +

    In 2021 we received further EU funding for two Next-Generation-Internet +proposals, namely for EPPD - email provider portability directory (~97K EUR) and AEAP - email address porting (~90K EUR) which resulted in better multi-profile support, improved QR-code contact and group setups and many networking improvements on all platforms.

    +
  • +
  • +

    From End 2021 till March 2023 we received Internet Freedom funding (500K USD) from the +U.S. Bureau of Democracy, Human Rights and Labor (DRL). +This funding supported our long-running goals to make Delta Chat more usable +and compatible with a wide range of email servers world-wide, and more resilient and secure +in places often affected by internet censorship and shutdowns.

    +
  • +
  • +

    2023-2024 年,我们成功完成了 OTF 资助的 +安全 Chatmail 项目, +使我们能够引入保证的加密, +创建一个 Chatmail 服务器网络, +并在 2024 年 4 月起发布的所有应用中提供“即时入职”。

    +
  • +
  • +

    在 2023 年和 2024 年,我们的 WebXDC PUSH 工作已在下一代互联网 (NGI) 中获得认可, +并与致力于 +WebXDC evolve、 +WebXDC XMPP、 +DeltaTouch 和 +DeltaTauri 的合作伙伴合作。 +所有这些项目都已部分完成或将在 2025 年初完成。

    +
  • +
  • +

    我们有时会收到个人的一次性捐款。 例如,2021 年,一位慷慨的人士以 “继续保持良好的发展态势!”为主题通过银行向我们捐赠了4千欧元💜。 我们用这些钱来资助发展活动或支付不容易预测或从公共基金中报销的临时费用。收到更多的捐款也有助于我们作为一个贡献者社区变得更加独立和长期可持续。

    + + +
  • +
  • +

    最后但并非最不重要的是,数位专家与热心人在没有收到或仅收到少量金钱的情况下为 Delta Chat 的开发做出了贡献。没有他们,Delta Chat 不会发展到、甚至无法接近目前的状况。

    +
  • +
+ +

上面提到的钱款资助主要是由在弗赖堡(德国)的 merlinux GmbH 组织的,分发给了来自世界各地的十多位贡献者。

+ +

请参阅 Delta Chat 捐款渠道 +以了解货币捐款和其他捐款的可能性。

+ + + + + \ No newline at end of file diff --git a/src/main/assets/webxdc/maps.xdc b/src/main/assets/webxdc/maps.xdc new file mode 100644 index 0000000000000000000000000000000000000000..b62f13a460f24147e6b815372d8ec1b341a2a104 Binary files /dev/null and b/src/main/assets/webxdc/maps.xdc differ diff --git a/src/main/ic_launcher-playstore.png b/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..9ef22ffea785afb4e2742ab2ae8478cd535c6e82 Binary files /dev/null and b/src/main/ic_launcher-playstore.png differ diff --git a/src/main/java/chat/delta/rpc/BaseTransport.java b/src/main/java/chat/delta/rpc/BaseTransport.java new file mode 100644 index 0000000000000000000000000000000000000000..971bb130fcf234ae93762e5fb74e7dd8c7a4d593 --- /dev/null +++ b/src/main/java/chat/delta/rpc/BaseTransport.java @@ -0,0 +1,124 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc; + +import chat.delta.util.SettableFuture; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; + +/* Basic RPC Transport implementation */ +public abstract class BaseTransport implements Rpc.Transport { + private final Map> requestFutures = new ConcurrentHashMap<>(); + private int requestId = 0; + private final ObjectMapper mapper = new ObjectMapper(); + private Thread worker; + + /* Send a Request as raw JSON String to the RPC server */ + protected abstract void sendRequest(String jsonRequest); + + /* Get next Response as raw JSON String from the RPC server */ + protected abstract String getResponse(); + + public ObjectMapper getObjectMapper() { + return mapper; + } + + public void call(String method, JsonNode... params) throws RpcException { + innerCall(method, params); + } + + public T callForResult(TypeReference resultType, String method, JsonNode... params) throws RpcException { + try { + JsonNode node = innerCall(method, params); + if (node.isNull()) return null; + return mapper.readValue(node.traverse(), resultType); + } catch (IOException e) { + throw new RpcException(e.getMessage()); + } + } + + private JsonNode innerCall(String method, JsonNode... params) throws RpcException { + int id; + synchronized (this) { + id = ++requestId; + ensureWorkerThread(); + } + try { + String jsonRequest = mapper.writeValueAsString(new Request(method, params, id)); + SettableFuture future = new SettableFuture<>(); + requestFutures.put(id, future); + sendRequest(jsonRequest); + return future.get(); + } catch (ExecutionException e) { + throw (RpcException)e.getCause(); + } catch (InterruptedException e) { + throw new RpcException(e.getMessage()); + } catch (JsonProcessingException e) { + throw new RpcException(e.getMessage()); + } + } + + private void ensureWorkerThread() { + if (worker != null) return; + + worker = new Thread(() -> { + while (true) { + try { + processResponse(); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + }, "jsonrpcThread"); + worker.start(); + } + + private void processResponse() throws JsonProcessingException { + String jsonResponse = getResponse(); + Response response = mapper.readValue(jsonResponse, Response.class); + + if (response.id == 0) { // Got JSON-RPC notification/event, ignore + return; + } + + SettableFuture future = requestFutures.remove(response.id); + if (future == null) { // Got a response with unknown ID, ignore + return; + } + + if (response.error != null) { + future.setException(new RpcException(response.error.toString())); + } else if (response.result != null) { + future.set(response.result); + } else { + future.setException(new RpcException("Got JSON-RPC response without result or error: " + jsonResponse)); + } + } + + private static class Request { + private final String jsonrpc = "2.0"; + public final String method; + public final JsonNode[] params; + public final int id; + + public Request(String method, JsonNode[] params, int id) { + this.method = method; + this.params = params; + this.id = id; + } + } + + private static class Response { + public String jsonrpc; + public int id; + public JsonNode result; + public JsonNode error; + } +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/Rpc.java b/src/main/java/chat/delta/rpc/Rpc.java new file mode 100644 index 0000000000000000000000000000000000000000..fa951a6968a2e8307ef3a1c23174cd856d32a521 --- /dev/null +++ b/src/main/java/chat/delta/rpc/Rpc.java @@ -0,0 +1,1467 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import chat.delta.rpc.types.*; + +public class Rpc { + + public interface Transport { + void call(String method, JsonNode... params) throws RpcException; + T callForResult(TypeReference resultType, String method, JsonNode... params) throws RpcException; + ObjectMapper getObjectMapper(); + } + + public final Transport transport; + private final ObjectMapper mapper; + + public Rpc(Transport transport) { + this.transport = transport; + this.mapper = transport.getObjectMapper(); + } + + /* Test function. */ + public void sleep(Float delay) throws RpcException { + transport.call("sleep", mapper.valueToTree(delay)); + } + + /* Checks if an email address is valid. */ + public Boolean checkEmailValidity(String email) throws RpcException { + return transport.callForResult(new TypeReference(){}, "check_email_validity", mapper.valueToTree(email)); + } + + /* Returns general system info. */ + public java.util.Map getSystemInfo() throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_system_info"); + } + + /** + * Get the next event, and remove it from the event queue. + *

+ * If no events have happened since the last `get_next_event` + * (i.e. if the event queue is empty), the response will be returned + * only when a new event fires. + *

+ * Note that if you are using the `BaseDeltaChat` JavaScript class + * or the `Rpc` Python class, this function will be invoked + * by those classes internally and should not be used manually. + */ + public Event getNextEvent() throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_next_event"); + } + + public Integer addAccount() throws RpcException { + return transport.callForResult(new TypeReference(){}, "add_account"); + } + + /** + * Imports/migrated an existing account from a database path into this account manager. + * Returns the ID of new account. + */ + public Integer migrateAccount(String pathToDb) throws RpcException { + return transport.callForResult(new TypeReference(){}, "migrate_account", mapper.valueToTree(pathToDb)); + } + + public void removeAccount(Integer accountId) throws RpcException { + transport.call("remove_account", mapper.valueToTree(accountId)); + } + + public java.util.List getAllAccountIds() throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_all_account_ids"); + } + + /* Select account in account manager, this saves the last used account to accounts.toml */ + public void selectAccount(Integer id) throws RpcException { + transport.call("select_account", mapper.valueToTree(id)); + } + + /* Get the selected account from the account manager (on startup it is read from accounts.toml) */ + public Integer getSelectedAccountId() throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_selected_account_id"); + } + + /** + * Set the order of accounts. + * The provided list should contain all account IDs in the desired order. + * If an account ID is missing from the list, it will be appended at the end. + * If the list contains non-existent account IDs, they will be ignored. + */ + public void setAccountsOrder(java.util.List order) throws RpcException { + transport.call("set_accounts_order", mapper.valueToTree(order)); + } + + /* Get a list of all configured accounts. */ + public java.util.List getAllAccounts() throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_all_accounts"); + } + + /* Starts background tasks for all accounts. */ + public void startIoForAllAccounts() throws RpcException { + transport.call("start_io_for_all_accounts"); + } + + /* Stops background tasks for all accounts. */ + public void stopIoForAllAccounts() throws RpcException { + transport.call("stop_io_for_all_accounts"); + } + + /** + * Performs a background fetch for all accounts in parallel with a timeout. + *

+ * The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout. + * Process all events until you get this one and you can safely return to the background + * without forgetting to create notifications caused by timing race conditions. + */ + public void backgroundFetch(Float timeoutInSeconds) throws RpcException { + transport.call("background_fetch", mapper.valueToTree(timeoutInSeconds)); + } + + public void stopBackgroundFetch() throws RpcException { + transport.call("stop_background_fetch"); + } + + /* Starts background tasks for a single account. */ + public void startIo(Integer accountId) throws RpcException { + transport.call("start_io", mapper.valueToTree(accountId)); + } + + /* Stops background tasks for a single account. */ + public void stopIo(Integer accountId) throws RpcException { + transport.call("stop_io", mapper.valueToTree(accountId)); + } + + /* Get top-level info for an account. */ + public Account getAccountInfo(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_account_info", mapper.valueToTree(accountId)); + } + + /* Get the current push notification state. */ + public NotifyState getPushState(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_push_state", mapper.valueToTree(accountId)); + } + + /* Get the combined filesize of an account in bytes */ + public Integer getAccountFileSize(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_account_file_size", mapper.valueToTree(accountId)); + } + + /** + * Returns provider for the given domain. + *

+ * This function looks up domain in offline database. + *

+ * For compatibility, email address can be passed to this function + * instead of the domain. + */ + public ProviderInfo getProviderInfo(Integer accountId, String email) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_provider_info", mapper.valueToTree(accountId), mapper.valueToTree(email)); + } + + /* Checks if the context is already configured. */ + public Boolean isConfigured(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "is_configured", mapper.valueToTree(accountId)); + } + + /* Get system info for an account. */ + public java.util.Map getInfo(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_info", mapper.valueToTree(accountId)); + } + + /* Get storage usage report as formatted string */ + public String getStorageUsageReportString(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_storage_usage_report_string", mapper.valueToTree(accountId)); + } + + /* Get the blob dir. */ + public String getBlobDir(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_blob_dir", mapper.valueToTree(accountId)); + } + + /** + * If there was an error while the account was opened + * and migrated to the current version, + * then this function returns it. + *

+ * This function is useful because the key-contacts migration could fail due to bugs + * and then the account will not work properly. + *

+ * After opening an account, the UI should call this function + * and show the error string if one is returned. + */ + public String getMigrationError(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_migration_error", mapper.valueToTree(accountId)); + } + + /* Copy file to blob dir. */ + public String copyToBlobDir(Integer accountId, String path) throws RpcException { + return transport.callForResult(new TypeReference(){}, "copy_to_blob_dir", mapper.valueToTree(accountId), mapper.valueToTree(path)); + } + + /* Sets the given configuration key. */ + public void setConfig(Integer accountId, String key, String value) throws RpcException { + transport.call("set_config", mapper.valueToTree(accountId), mapper.valueToTree(key), mapper.valueToTree(value)); + } + + /* Updates a batch of configuration values. */ + public void batchSetConfig(Integer accountId, java.util.Map config) throws RpcException { + transport.call("batch_set_config", mapper.valueToTree(accountId), mapper.valueToTree(config)); + } + + /** + * Set configuration values from a QR code. (technically from the URI that is stored in the qrcode) + * Before this function is called, `checkQr()` should confirm the type of the + * QR code is `account` or `webrtcInstance`. + *

+ * Internally, the function will call dc_set_config() with the appropriate keys, + */ + public void setConfigFromQr(Integer accountId, String qrContent) throws RpcException { + transport.call("set_config_from_qr", mapper.valueToTree(accountId), mapper.valueToTree(qrContent)); + } + + public Qr checkQr(Integer accountId, String qrContent) throws RpcException { + return transport.callForResult(new TypeReference(){}, "check_qr", mapper.valueToTree(accountId), mapper.valueToTree(qrContent)); + } + + /* Returns configuration value for the given key. */ + public String getConfig(Integer accountId, String key) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_config", mapper.valueToTree(accountId), mapper.valueToTree(key)); + } + + public java.util.Map batchGetConfig(Integer accountId, java.util.List keys) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "batch_get_config", mapper.valueToTree(accountId), mapper.valueToTree(keys)); + } + + public void setStockStrings(java.util.Map strings) throws RpcException { + transport.call("set_stock_strings", mapper.valueToTree(strings)); + } + + /** + * Configures this account with the currently set parameters. + * Setup the credential config before calling this. + *

+ * Deprecated as of 2025-02; use `add_transport_from_qr()` + * or `add_or_update_transport()` instead. + */ + public void configure(Integer accountId) throws RpcException { + transport.call("configure", mapper.valueToTree(accountId)); + } + + /** + * Configures a new email account using the provided parameters + * and adds it as a transport. + *

+ * If the email address is the same as an existing transport, + * then this existing account will be reconfigured instead of a new one being added. + *

+ * This function stops and starts IO as needed. + *

+ * Usually it will be enough to only set `addr` and `password`, + * and all the other settings will be autoconfigured. + *

+ * During configuration, ConfigureProgress events are emitted; + * they indicate a successful configuration as well as errors + * and may be used to create a progress bar. + * This function will return after configuration is finished. + *

+ * If configuration is successful, + * the working server parameters will be saved + * and used for connecting to the server. + * The parameters entered by the user will be saved separately + * so that they can be prefilled when the user opens the server-configuration screen again. + *

+ * See also: + * - [Self::is_configured()] to check whether there is + * at least one working transport. + * - [Self::add_transport_from_qr()] to add a transport + * from a server encoded in a QR code. + * - [Self::list_transports()] to get a list of all configured transports. + * - [Self::delete_transport()] to remove a transport. + */ + public void addOrUpdateTransport(Integer accountId, EnteredLoginParam param) throws RpcException { + transport.call("add_or_update_transport", mapper.valueToTree(accountId), mapper.valueToTree(param)); + } + + /* Deprecated 2025-04. Alias for [Self::add_or_update_transport()]. */ + public void addTransport(Integer accountId, EnteredLoginParam param) throws RpcException { + transport.call("add_transport", mapper.valueToTree(accountId), mapper.valueToTree(param)); + } + + /** + * Adds a new email account as a transport + * using the server encoded in the QR code. + * See [Self::add_or_update_transport]. + */ + public void addTransportFromQr(Integer accountId, String qr) throws RpcException { + transport.call("add_transport_from_qr", mapper.valueToTree(accountId), mapper.valueToTree(qr)); + } + + /** + * Returns the list of all email accounts that are used as a transport in the current profile. + * Use [Self::add_or_update_transport()] to add or change a transport + * and [Self::delete_transport()] to delete a transport. + */ + public java.util.List listTransports(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "list_transports", mapper.valueToTree(accountId)); + } + + /** + * Removes the transport with the specified email address + * (i.e. [EnteredLoginParam::addr]). + */ + public void deleteTransport(Integer accountId, String addr) throws RpcException { + transport.call("delete_transport", mapper.valueToTree(accountId), mapper.valueToTree(addr)); + } + + /* Signal an ongoing process to stop. */ + public void stopOngoingProcess(Integer accountId) throws RpcException { + transport.call("stop_ongoing_process", mapper.valueToTree(accountId)); + } + + public void exportSelfKeys(Integer accountId, String path, String passphrase) throws RpcException { + transport.call("export_self_keys", mapper.valueToTree(accountId), mapper.valueToTree(path), mapper.valueToTree(passphrase)); + } + + public void importSelfKeys(Integer accountId, String path, String passphrase) throws RpcException { + transport.call("import_self_keys", mapper.valueToTree(accountId), mapper.valueToTree(path), mapper.valueToTree(passphrase)); + } + + /** + * Returns the message IDs of all _fresh_ messages of any chat. + * Typically used for implementing notification summaries + * or badge counters e.g. on the app icon. + * The list is already sorted and starts with the most recent fresh message. + *

+ * Messages belonging to muted chats or to the contact requests are not returned; + * these messages should not be notified + * and also badge counters should not include these messages. + *

+ * To get the number of fresh messages for a single chat, muted or not, + * use `get_fresh_msg_cnt()`. + */ + public java.util.List getFreshMsgs(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_fresh_msgs", mapper.valueToTree(accountId)); + } + + /** + * Get the number of _fresh_ messages in a chat. + * Typically used to implement a badge with a number in the chatlist. + *

+ * If the specified chat is muted, + * the UI should show the badge counter "less obtrusive", + * e.g. using "gray" instead of "red" color. + */ + public Integer getFreshMsgCnt(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_fresh_msg_cnt", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Gets messages to be processed by the bot and returns their IDs. + *

+ * Only messages with database ID higher than `last_msg_id` config value + * are returned. After processing the messages, the bot should + * update `last_msg_id` by calling [`markseen_msgs`] + * or manually updating the value to avoid getting already + * processed messages. + *

+ * [`markseen_msgs`]: Self::markseen_msgs + */ + public java.util.List getNextMsgs(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_next_msgs", mapper.valueToTree(accountId)); + } + + /** + * Waits for messages to be processed by the bot and returns their IDs. + *

+ * This function is similar to [`get_next_msgs`], + * but waits for internal new message notification before returning. + * New message notification is sent when new message is added to the database, + * on initialization, when I/O is started and when I/O is stopped. + * This allows bots to use `wait_next_msgs` in a loop to process + * old messages after initialization and during the bot runtime. + * To shutdown the bot, stopping I/O can be used to interrupt + * pending or next `wait_next_msgs` call. + *

+ * [`get_next_msgs`]: Self::get_next_msgs + */ + public java.util.List waitNextMsgs(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "wait_next_msgs", mapper.valueToTree(accountId)); + } + + /** + * Estimate the number of messages that will be deleted + * by the set_config()-options `delete_device_after` or `delete_server_after`. + * This is typically used to show the estimated impact to the user + * before actually enabling deletion of old messages. + */ + public Integer estimateAutoDeletionCount(Integer accountId, Boolean fromServer, Integer seconds) throws RpcException { + return transport.callForResult(new TypeReference(){}, "estimate_auto_deletion_count", mapper.valueToTree(accountId), mapper.valueToTree(fromServer), mapper.valueToTree(seconds)); + } + + public String initiateAutocryptKeyTransfer(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "initiate_autocrypt_key_transfer", mapper.valueToTree(accountId)); + } + + public void continueAutocryptKeyTransfer(Integer accountId, Integer messageId, String setupCode) throws RpcException { + transport.call("continue_autocrypt_key_transfer", mapper.valueToTree(accountId), mapper.valueToTree(messageId), mapper.valueToTree(setupCode)); + } + + public java.util.List getChatlistEntries(Integer accountId, Integer listFlags, String queryString, Integer queryContactId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_chatlist_entries", mapper.valueToTree(accountId), mapper.valueToTree(listFlags), mapper.valueToTree(queryString), mapper.valueToTree(queryContactId)); + } + + /** + * Returns chats similar to the given one. + *

+ * Experimental API, subject to change without notice. + */ + public java.util.List getSimilarChatIds(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_similar_chat_ids", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + public java.util.Map getChatlistItemsByEntries(Integer accountId, java.util.List entries) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_chatlist_items_by_entries", mapper.valueToTree(accountId), mapper.valueToTree(entries)); + } + + public FullChat getFullChatById(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_full_chat_by_id", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * get basic info about a chat, + * use chatlist_get_full_chat_by_id() instead if you need more information + */ + public BasicChat getBasicChatInfo(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_basic_chat_info", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + public void acceptChat(Integer accountId, Integer chatId) throws RpcException { + transport.call("accept_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + public void blockChat(Integer accountId, Integer chatId) throws RpcException { + transport.call("block_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Delete a chat. + *

+ * Messages are deleted from the device and the chat database entry is deleted. + * After that, the event #DC_EVENT_MSGS_CHANGED is posted. + *

+ * Things that are _not done_ implicitly: + *

+ * - Messages are **not deleted from the server**. + * - The chat or the contact is **not blocked**, so new messages from the user/the group may appear as a contact request + * and the user may create the chat again. + * - **Groups are not left** - this would + * be unexpected as (1) deleting a normal chat also does not prevent new mails + * from arriving, (2) leaving a group requires sending a message to + * all group members - especially for groups not used for a longer time, this is + * really unexpected when deletion results in contacting all members again, + * (3) only leaving groups is also a valid usecase. + *

+ * To leave a chat explicitly, use leave_group() + */ + public void deleteChat(Integer accountId, Integer chatId) throws RpcException { + transport.call("delete_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Get encryption info for a chat. + * Get a multi-line encryption info, containing encryption preferences of all members. + * Can be used to find out why messages sent to group are not encrypted. + *

+ * returns Multi-line text + */ + public String getChatEncryptionInfo(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_chat_encryption_info", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation. + *

+ * If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned. + * If `chat_id` is unset, setup contact QR code is returned. + */ + public String getChatSecurejoinQrCode(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_chat_securejoin_qr_code", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Get QR code (text and SVG) that will offer a Setup-Contact or Verified-Group invitation. + * The QR code is compatible to the OPENPGP4FPR format + * so that a basic fingerprint comparison also works e.g. with OpenKeychain. + *

+ * The scanning device will pass the scanned content to `checkQr()` then; + * if `checkQr()` returns `askVerifyContact` or `askVerifyGroup` + * an out-of-band-verification can be joined using `secure_join()` + *

+ * chat_id: If set to a group-chat-id, + * the Verified-Group-Invite protocol is offered in the QR code; + * works for protected groups as well as for normal groups. + * If not set, the Setup-Contact protocol is offered in the QR code. + * See https://securejoin.delta.chat/ for details about both protocols. + *

+ * return format: `[code, svg]` + */ + public Pair getChatSecurejoinQrCodeSvg(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_chat_securejoin_qr_code_svg", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Continue a Setup-Contact or Verified-Group-Invite protocol + * started on another device with `get_chat_securejoin_qr_code_svg()`. + * This function is typically called when `check_qr()` returns + * type=AskVerifyContact or type=AskVerifyGroup. + *

+ * The function returns immediately and the handshake runs in background, + * sending and receiving several messages. + * During the handshake, info messages are added to the chat, + * showing progress, success or errors. + *

+ * Subsequent calls of `secure_join()` will abort previous, unfinished handshakes. + *

+ * See https://securejoin.delta.chat/ for details about both protocols. + *

+ * **qr**: The text of the scanned QR code. Typically, the same string as given + * to `check_qr()`. + *

+ * **returns**: The chat ID of the joined chat, the UI may redirect to the this chat. + * A returned chat ID does not guarantee that the chat is protected or the belonging contact is verified. + *

+ */ + public Integer secureJoin(Integer accountId, String qr) throws RpcException { + return transport.callForResult(new TypeReference(){}, "secure_join", mapper.valueToTree(accountId), mapper.valueToTree(qr)); + } + + /** + * Like `secure_join()`, but allows to pass a source and a UI-path. + * You only need this if your UI has an option to send statistics + * to Delta Chat's developers. + *

+ * **source**: The source where the QR code came from. + * E.g. a link that was clicked inside or outside Delta Chat, + * the "Paste from Clipboard" action, + * the "Load QR code as image" action, + * or a QR code scan. + *

+ * **uipath**: Which UI path did the user use to arrive at the QR code screen. + * If the SecurejoinSource was ExternalLink or InternalLink, + * pass `None` here, because the QR code screen wasn't even opened. + * ``` + */ + public Integer secureJoinWithUxInfo(Integer accountId, String qr, SecurejoinSource source, SecurejoinUiPath uipath) throws RpcException { + return transport.callForResult(new TypeReference(){}, "secure_join_with_ux_info", mapper.valueToTree(accountId), mapper.valueToTree(qr), mapper.valueToTree(source), mapper.valueToTree(uipath)); + } + + public void leaveGroup(Integer accountId, Integer chatId) throws RpcException { + transport.call("leave_group", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Remove a member from a group. + *

+ * If the group is already _promoted_ (any message was sent to the group), + * all group members are informed by a special status message that is sent automatically by this function. + *

+ * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + */ + public void removeContactFromChat(Integer accountId, Integer chatId, Integer contactId) throws RpcException { + transport.call("remove_contact_from_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(contactId)); + } + + /** + * Add a member to a group. + *

+ * If the group is already _promoted_ (any message was sent to the group), + * all group members are informed by a special status message that is sent automatically by this function. + *

+ * If the group has group protection enabled, only verified contacts can be added to the group. + *

+ * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + */ + public void addContactToChat(Integer accountId, Integer chatId, Integer contactId) throws RpcException { + transport.call("add_contact_to_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(contactId)); + } + + /** + * Get the contact IDs belonging to a chat. + *

+ * - for normal chats, the function always returns exactly one contact, + * DC_CONTACT_ID_SELF is returned only for SELF-chats. + *

+ * - for group chats all members are returned, DC_CONTACT_ID_SELF is returned + * explicitly as it may happen that oneself gets removed from a still existing + * group + *

+ * - for broadcast channels, all recipients are returned, DC_CONTACT_ID_SELF is not included + *

+ * - for mailing lists, the behavior is not documented currently, we will decide on that later. + * for now, the UI should not show the list for mailing lists. + * (we do not know all members and there is not always a global mailing list address, + * so we could return only SELF or the known members; this is not decided yet) + */ + public java.util.List getChatContacts(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_chat_contacts", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /* Returns contact IDs of the past chat members. */ + public java.util.List getPastChatContacts(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_past_chat_contacts", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Create a new encrypted group chat (with key-contacts). + *

+ * After creation, + * the group has one member with the ID DC_CONTACT_ID_SELF + * and is in _unpromoted_ state. + * This means, you can add or remove members, change the name, + * the group image and so on without messages being sent to all group members. + *

+ * This changes as soon as the first message is sent to the group members + * and the group becomes _promoted_. + * After that, all changes are synced with all group members + * by sending status message. + *

+ * To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of `BasicChat` or `FullChat`. + * This may be useful if you want to show some help for just created groups. + *

+ * `protect` argument is deprecated as of 2025-10-22 and is left for compatibility. + * Pass `false` here. + */ + public Integer createGroupChat(Integer accountId, String name, Boolean protect) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_group_chat", mapper.valueToTree(accountId), mapper.valueToTree(name), mapper.valueToTree(protect)); + } + + /** + * Create a new unencrypted group chat. + *

+ * Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have + * address-contacts. + */ + public Integer createGroupChatUnencrypted(Integer accountId, String name) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_group_chat_unencrypted", mapper.valueToTree(accountId), mapper.valueToTree(name)); + } + + /* Deprecated 2025-07 in favor of create_broadcast(). */ + public Integer createBroadcastList(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_broadcast_list", mapper.valueToTree(accountId)); + } + + /** + * Create a new, outgoing **broadcast channel** + * (called "Channel" in the UI). + *

+ * Broadcast channels are similar to groups on the sending device, + * however, recipients get the messages in a read-only chat + * and will not see who the other members are. + *

+ * Called `broadcast` here rather than `channel`, + * because the word "channel" already appears a lot in the code, + * which would make it hard to grep for it. + *

+ * After creation, the chat contains no recipients and is in _unpromoted_ state; + * see [`CommandApi::create_group_chat`] for more information on the unpromoted state. + *

+ * Returns the created chat's id. + */ + public Integer createBroadcast(Integer accountId, String chatName) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_broadcast", mapper.valueToTree(accountId), mapper.valueToTree(chatName)); + } + + /** + * Set group name. + *

+ * If the group is already _promoted_ (any message was sent to the group), + * all group members are informed by a special status message that is sent automatically by this function. + *

+ * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + */ + public void setChatName(Integer accountId, Integer chatId, String newName) throws RpcException { + transport.call("set_chat_name", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(newName)); + } + + /** + * Set group profile image. + *

+ * If the group is already _promoted_ (any message was sent to the group), + * all group members are informed by a special status message that is sent automatically by this function. + *

+ * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + *

+ * To find out the profile image of a chat, use dc_chat_get_profile_image() + *

+ * @param image_path Full path of the image to use as the group image. The image will immediately be copied to the + * `blobdir`; the original image will not be needed anymore. + * If you pass null here, the group image is deleted (for promoted groups, all members are informed about + * this change anyway). + */ + public void setChatProfileImage(Integer accountId, Integer chatId, String imagePath) throws RpcException { + transport.call("set_chat_profile_image", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(imagePath)); + } + + public void setChatVisibility(Integer accountId, Integer chatId, ChatVisibility visibility) throws RpcException { + transport.call("set_chat_visibility", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(visibility)); + } + + public void setChatEphemeralTimer(Integer accountId, Integer chatId, Integer timer) throws RpcException { + transport.call("set_chat_ephemeral_timer", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(timer)); + } + + public Integer getChatEphemeralTimer(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_chat_ephemeral_timer", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Add a message to the device-chat. + * Device-messages usually contain update information + * and some hints that are added during the program runs, multi-device etc. + * The device-message may be defined by a label; + * if a message with the same label was added or skipped before, + * the message is not added again, even if the message was deleted in between. + * If needed, the device-chat is created before. + *

+ * Sends the `MsgsChanged` event on success. + *

+ * Setting msg to None will prevent the device message with this label from being added in the future. + */ + public Integer addDeviceMessage(Integer accountId, String label, MessageData msg) throws RpcException { + return transport.callForResult(new TypeReference(){}, "add_device_message", mapper.valueToTree(accountId), mapper.valueToTree(label), mapper.valueToTree(msg)); + } + + /** + * Mark all messages in a chat as _noticed_. + * _Noticed_ messages are no longer _fresh_ and do not count as being unseen + * but are still waiting for being marked as "seen" using markseen_msgs() + * (IMAP/MDNs is not done for noticed messages). + *

+ * Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED. + * See also markseen_msgs(). + */ + public void marknoticedChat(Integer accountId, Integer chatId) throws RpcException { + transport.call("marknoticed_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Returns the message that is immediately followed by the last seen + * message. + * From the point of view of the user this is effectively + * "first unread", but in reality in the database a seen message + * _can_ be followed by a fresh (unseen) message + * if that message has not been individually marked as seen. + */ + public Integer getFirstUnreadMessageOfChat(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_first_unread_message_of_chat", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Set mute duration of a chat. + *

+ * The UI can then call is_chat_muted() when receiving a new message + * to decide whether it should trigger an notification. + *

+ * Muted chats should not sound or vibrate + * and should not show a visual notification in the system area. + * Moreover, muted chats should be excluded from global badge counter + * (get_fresh_msgs() skips muted chats therefore) + * and the in-app, per-chat badge counter should use a less obtrusive color. + *

+ * Sends out #DC_EVENT_CHAT_MODIFIED. + */ + public void setChatMuteDuration(Integer accountId, Integer chatId, MuteDuration duration) throws RpcException { + transport.call("set_chat_mute_duration", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(duration)); + } + + /** + * Check whether the chat is currently muted (can be changed by set_chat_mute_duration()). + *

+ * This is available as a standalone function outside of fullchat, because it might be only needed for notification + */ + public Boolean isChatMuted(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "is_chat_muted", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Mark messages as presented to the user. + * Typically, UIs call this function on scrolling through the message list, + * when the messages are presented at least for a little moment. + * The concrete action depends on the type of the chat and on the users settings + * (dc_msgs_presented() may be a better name therefore, but well. :) + *

+ * - For normal chats, the IMAP state is updated, MDN is sent + * (if set_config()-options `mdns_enabled` is set) + * and the internal state is changed to @ref DC_STATE_IN_SEEN to reflect these actions. + *

+ * - For contact requests, no IMAP or MDNs is done + * and the internal state is not changed therefore. + * See also marknoticed_chat(). + *

+ * Moreover, timer is started for incoming ephemeral messages. + * This also happens for contact requests chats. + *

+ * This function updates `last_msg_id` configuration value + * to the maximum of the current value and IDs passed to this function. + * Bots which mark messages as seen can rely on this side effect + * to avoid updating `last_msg_id` value manually. + *

+ * One #DC_EVENT_MSGS_NOTICED event is emitted per modified chat. + */ + public void markseenMsgs(Integer accountId, java.util.List msgIds) throws RpcException { + transport.call("markseen_msgs", mapper.valueToTree(accountId), mapper.valueToTree(msgIds)); + } + + /** + * Returns all messages of a particular chat. + *

+ * * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result, + * e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the + * corresponding (following) day in the local timezone. + */ + public java.util.List getMessageIds(Integer accountId, Integer chatId, Boolean infoOnly, Boolean addDaymarker) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_message_ids", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(infoOnly), mapper.valueToTree(addDaymarker)); + } + + /** + * Checks if the messages with given IDs exist. + *

+ * Returns IDs of existing messages. + */ + public java.util.List getExistingMsgIds(Integer accountId, java.util.List msgIds) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_existing_msg_ids", mapper.valueToTree(accountId), mapper.valueToTree(msgIds)); + } + + public java.util.List getMessageListItems(Integer accountId, Integer chatId, Boolean infoOnly, Boolean addDaymarker) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_message_list_items", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(infoOnly), mapper.valueToTree(addDaymarker)); + } + + public Message getMessage(Integer accountId, Integer msgId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message", mapper.valueToTree(accountId), mapper.valueToTree(msgId)); + } + + public String getMessageHtml(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message_html", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /** + * get multiple messages in one call, + * if loading one message fails the error is stored in the result object in it's place. + *

+ * this is the batch variant of [get_message] + */ + public java.util.Map getMessages(Integer accountId, java.util.List messageIds) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_messages", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + /* Fetch info desktop needs for creating a notification for a message */ + public MessageNotificationInfo getMessageNotificationInfo(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message_notification_info", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /** + * Delete messages. The messages are deleted on the current device and + * on the IMAP server. + */ + public void deleteMessages(Integer accountId, java.util.List messageIds) throws RpcException { + transport.call("delete_messages", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + /** + * Delete messages. The messages are deleted on the current device, + * on the IMAP server and also for all chat members + */ + public void deleteMessagesForAll(Integer accountId, java.util.List messageIds) throws RpcException { + transport.call("delete_messages_for_all", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + /** + * Get an informational text for a single message. The text is multiline and may + * contain e.g. the raw text of the message. + *

+ * The max. text returned is typically longer (about 100000 characters) than the + * max. text returned by dc_msg_get_text() (about 30000 characters). + */ + public String getMessageInfo(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message_info", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /* Returns additional information for single message. */ + public MessageInfo getMessageInfoObject(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message_info_object", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /* Returns contacts that sent read receipts and the time of reading. */ + public java.util.List getMessageReadReceipts(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_message_read_receipts", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /** + * Asks the core to start downloading a message fully. + * This function is typically called when the user hits the "Download" button + * that is shown by the UI in case `download_state` is `'Available'` or `'Failure'` + *

+ * On success, the @ref DC_MSG "view type of the message" may change + * or the message may be replaced completely by one or more messages with other message IDs. + * That may happen e.g. in cases where the message was encrypted + * and the type could not be determined without fully downloading. + * Downloaded content can be accessed as usual after download. + *

+ * To reflect these changes a @ref DC_EVENT_MSGS_CHANGED event will be emitted. + */ + public void downloadFullMessage(Integer accountId, Integer messageId) throws RpcException { + transport.call("download_full_message", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + /** + * Search messages containing the given query string. + * Searching can be done globally (chat_id=None) or in a specified chat only (chat_id set). + *

+ * Global search results are typically displayed using dc_msg_get_summary(), chat + * search results may just highlight the corresponding messages and present a + * prev/next button. + *

+ * For the global search, the result is limited to 1000 messages, + * this allows an incremental search done fast. + * So, when getting exactly 1000 messages, the result actually may be truncated; + * the UIs may display sth. like "1000+ messages found" in this case. + * The chat search (if chat_id is set) is not limited. + */ + public java.util.List searchMessages(Integer accountId, String query, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "search_messages", mapper.valueToTree(accountId), mapper.valueToTree(query), mapper.valueToTree(chatId)); + } + + public java.util.Map messageIdsToSearchResults(Integer accountId, java.util.List messageIds) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "message_ids_to_search_results", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + public void saveMsgs(Integer accountId, java.util.List messageIds) throws RpcException { + transport.call("save_msgs", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + /* Get a single contact options by ID. */ + public Contact getContact(Integer accountId, Integer contactId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_contact", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + /** + * Add a single contact as a result of an explicit user action. + *

+ * This will always create or look up an address-contact, + * i.e. a contact identified by an email address, + * with all messages sent to and from this contact being unencrypted. + * If the user just clicked on an email address, + * you should first check [`Self::lookup_contact_id_by_addr`]/`lookupContactIdByAddr.`, + * and only if there is no contact yet, call this function here. + *

+ * Returns contact id of the created or existing contact. + */ + public Integer createContact(Integer accountId, String email, String name) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_contact", mapper.valueToTree(accountId), mapper.valueToTree(email), mapper.valueToTree(name)); + } + + /* Returns contact id of the created or existing DM chat with that contact */ + public Integer createChatByContactId(Integer accountId, Integer contactId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_chat_by_contact_id", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + public void blockContact(Integer accountId, Integer contactId) throws RpcException { + transport.call("block_contact", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + public void unblockContact(Integer accountId, Integer contactId) throws RpcException { + transport.call("unblock_contact", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + public java.util.List getBlockedContacts(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_blocked_contacts", mapper.valueToTree(accountId)); + } + + /** + * Returns ids of known and unblocked contacts. + *

+ * By default, key-contacts are listed. + *

+ * * `list_flags` - A combination of flags: + * - `DC_GCL_ADD_SELF` - Add SELF unless filtered by other parameters. + * - `DC_GCL_ADDRESS` - List address-contacts instead of key-contacts. + * * `query` - A string to filter the list. + */ + public java.util.List getContactIds(Integer accountId, Integer listFlags, String query) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_contact_ids", mapper.valueToTree(accountId), mapper.valueToTree(listFlags), mapper.valueToTree(query)); + } + + /** + * Returns known and unblocked contacts. + *

+ * Formerly called `getContacts2` in Desktop. + * See [`Self::get_contact_ids`] for parameters and more info. + */ + public java.util.List getContacts(Integer accountId, Integer listFlags, String query) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_contacts", mapper.valueToTree(accountId), mapper.valueToTree(listFlags), mapper.valueToTree(query)); + } + + public java.util.Map getContactsByIds(Integer accountId, java.util.List ids) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_contacts_by_ids", mapper.valueToTree(accountId), mapper.valueToTree(ids)); + } + + public void deleteContact(Integer accountId, Integer contactId) throws RpcException { + transport.call("delete_contact", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + /* Sets display name for existing contact. */ + public void changeContactName(Integer accountId, Integer contactId, String name) throws RpcException { + transport.call("change_contact_name", mapper.valueToTree(accountId), mapper.valueToTree(contactId), mapper.valueToTree(name)); + } + + /** + * Get encryption info for a contact. + * Get a multi-line encryption info, containing your fingerprint and the + * fingerprint of the contact, used e.g. to compare the fingerprints for a simple out-of-band verification. + */ + public String getContactEncryptionInfo(Integer accountId, Integer contactId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_contact_encryption_info", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + /** + * Looks up a known and unblocked contact with a given e-mail address. + * To get a list of all known and unblocked contacts, use contacts_get_contacts(). + *

+ * **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address + * (e.g. an address-contact and a key-contact), + * this looks up the most recently seen contact, + * i.e. which contact is returned depends on which contact last sent a message. + * If the user just clicked on a mailto: link, then this is the best thing you can do. + * But **DO NOT** internally represent contacts by their email address + * and do not use this function to look them up; + * otherwise this function will sometimes look up the wrong contact. + * Instead, you should internally represent contacts by their ids. + *

+ * To validate an e-mail address independently of the contact database + * use check_email_validity(). + */ + public Integer lookupContactIdByAddr(Integer accountId, String addr) throws RpcException { + return transport.callForResult(new TypeReference(){}, "lookup_contact_id_by_addr", mapper.valueToTree(accountId), mapper.valueToTree(addr)); + } + + /* Parses a vCard file located at the given path. Returns contacts in their original order. */ + public java.util.List parseVcard(String path) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "parse_vcard", mapper.valueToTree(path)); + } + + /** + * Imports contacts from a vCard file located at the given path. + *

+ * Returns the ids of created/modified contacts in the order they appear in the vCard. + */ + public java.util.List importVcard(Integer accountId, String path) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "import_vcard", mapper.valueToTree(accountId), mapper.valueToTree(path)); + } + + /** + * Imports contacts from a vCard. + *

+ * Returns the ids of created/modified contacts in the order they appear in the vCard. + */ + public java.util.List importVcardContents(Integer accountId, String vcard) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "import_vcard_contents", mapper.valueToTree(accountId), mapper.valueToTree(vcard)); + } + + /* Returns a vCard containing contacts with the given ids. */ + public String makeVcard(Integer accountId, java.util.List contacts) throws RpcException { + return transport.callForResult(new TypeReference(){}, "make_vcard", mapper.valueToTree(accountId), mapper.valueToTree(contacts)); + } + + /* Sets vCard containing the given contacts to the message draft. */ + public void setDraftVcard(Integer accountId, Integer msgId, java.util.List contacts) throws RpcException { + transport.call("set_draft_vcard", mapper.valueToTree(accountId), mapper.valueToTree(msgId), mapper.valueToTree(contacts)); + } + + /** + * Returns the [`ChatId`] for the 1:1 chat with `contact_id` if it exists. + *

+ * If it does not exist, `None` is returned. + */ + public Integer getChatIdByContactId(Integer accountId, Integer contactId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_chat_id_by_contact_id", mapper.valueToTree(accountId), mapper.valueToTree(contactId)); + } + + /** + * Returns all message IDs of the given types in a chat. + * Typically used to show a gallery. + *

+ * The list is already sorted and starts with the oldest message. + * Clients should not try to re-sort the list as this would be an expensive action + * and would result in inconsistencies between clients. + *

+ * Setting `chat_id` to `None` (`null` in typescript) means get messages with media + * from any chat of the currently used account. + */ + public java.util.List getChatMedia(Integer accountId, Integer chatId, Viewtype messageType, Viewtype orMessageType2, Viewtype orMessageType3) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_chat_media", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(messageType), mapper.valueToTree(orMessageType2), mapper.valueToTree(orMessageType3)); + } + + public void exportBackup(Integer accountId, String destination, String passphrase) throws RpcException { + transport.call("export_backup", mapper.valueToTree(accountId), mapper.valueToTree(destination), mapper.valueToTree(passphrase)); + } + + public void importBackup(Integer accountId, String path, String passphrase) throws RpcException { + transport.call("import_backup", mapper.valueToTree(accountId), mapper.valueToTree(path), mapper.valueToTree(passphrase)); + } + + /** + * Offers a backup for remote devices to retrieve. + *

+ * Can be canceled by stopping the ongoing process. Success or failure can be tracked + * via the `ImexProgress` event which should either reach `1000` for success or `0` for + * failure. + *

+ * This **stops IO** while it is running. + *

+ * Returns once a remote device has retrieved the backup, or is canceled. + */ + public void provideBackup(Integer accountId) throws RpcException { + transport.call("provide_backup", mapper.valueToTree(accountId)); + } + + /** + * Returns the text of the QR code for the running [`CommandApi::provide_backup`]. + *

+ * This QR code text can be used in [`CommandApi::get_backup`] on a second device to + * retrieve the backup and setup this second device. + *

+ * This call will block until the QR code is ready, + * even if there is no concurrent call to [`CommandApi::provide_backup`], + * but will fail after 60 seconds to avoid deadlocks. + */ + public String getBackupQr(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_backup_qr", mapper.valueToTree(accountId)); + } + + /** + * Returns the rendered QR code for the running [`CommandApi::provide_backup`]. + *

+ * This QR code can be used in [`CommandApi::get_backup`] on a second device to + * retrieve the backup and setup this second device. + *

+ * This call will block until the QR code is ready, + * even if there is no concurrent call to [`CommandApi::provide_backup`], + * but will fail after 60 seconds to avoid deadlocks. + *

+ * Returns the QR code rendered as an SVG image. + */ + public String getBackupQrSvg(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_backup_qr_svg", mapper.valueToTree(accountId)); + } + + /** + * Gets a backup from a remote provider. + *

+ * This retrieves the backup from a remote device over the network and imports it into + * the current device. + *

+ * Can be canceled by stopping the ongoing process. + *

+ * Do not forget to call start_io on the account after a successful import, + * otherwise it will not connect to the email server. + */ + public void getBackup(Integer accountId, String qrText) throws RpcException { + transport.call("get_backup", mapper.valueToTree(accountId), mapper.valueToTree(qrText)); + } + + /** + * Indicate that the network likely has come back. + * or just that the network conditions might have changed + */ + public void maybeNetwork() throws RpcException { + transport.call("maybe_network"); + } + + /** + * Get the current connectivity, i.e. whether the device is connected to the IMAP server. + * One of: + * - DC_CONNECTIVITY_NOT_CONNECTED (1000-1999): Show e.g. the string "Not connected" or a red dot + * - DC_CONNECTIVITY_CONNECTING (2000-2999): Show e.g. the string "Connecting…" or a yellow dot + * - DC_CONNECTIVITY_WORKING (3000-3999): Show e.g. the string "Getting new messages" or a spinning wheel + * - DC_CONNECTIVITY_CONNECTED (>=4000): Show e.g. the string "Connected" or a green dot + *

+ * We don't use exact values but ranges here so that we can split up + * states into multiple states in the future. + *

+ * Meant as a rough overview that can be shown + * e.g. in the title of the main screen. + *

+ * If the connectivity changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted. + */ + public Integer getConnectivity(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_connectivity", mapper.valueToTree(accountId)); + } + + /** + * Get an overview of the current connectivity, and possibly more statistics. + * Meant to give the user more insight about the current status than + * the basic connectivity info returned by get_connectivity(); show this + * e.g., if the user taps on said basic connectivity info. + *

+ * If this page changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted. + *

+ * This comes as an HTML from the core so that we can easily improve it + * and the improvement instantly reaches all UIs. + */ + public String getConnectivityHtml(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_connectivity_html", mapper.valueToTree(accountId)); + } + + public java.util.List getLocations(Integer accountId, Integer chatId, Integer contactId, Integer timestampBegin, Integer timestampEnd) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "get_locations", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(contactId), mapper.valueToTree(timestampBegin), mapper.valueToTree(timestampEnd)); + } + + public void sendWebxdcStatusUpdate(Integer accountId, Integer instanceMsgId, String updateStr, String descr) throws RpcException { + transport.call("send_webxdc_status_update", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId), mapper.valueToTree(updateStr), mapper.valueToTree(descr)); + } + + public void sendWebxdcRealtimeData(Integer accountId, Integer instanceMsgId, java.util.List data) throws RpcException { + transport.call("send_webxdc_realtime_data", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId), mapper.valueToTree(data)); + } + + public void sendWebxdcRealtimeAdvertisement(Integer accountId, Integer instanceMsgId) throws RpcException { + transport.call("send_webxdc_realtime_advertisement", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId)); + } + + /** + * Leaves the gossip of the webxdc with the given message id. + *

+ * NB: When this is called before closing a webxdc app in UIs, it must be guaranteed that + * `send_webxdc_realtime_*()` functions aren't called for the given `instance_message_id` + * anymore until the app is open again. + */ + public void leaveWebxdcRealtime(Integer accountId, Integer instanceMessageId) throws RpcException { + transport.call("leave_webxdc_realtime", mapper.valueToTree(accountId), mapper.valueToTree(instanceMessageId)); + } + + public String getWebxdcStatusUpdates(Integer accountId, Integer instanceMsgId, Integer lastKnownSerial) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_webxdc_status_updates", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId), mapper.valueToTree(lastKnownSerial)); + } + + /* Get info from a webxdc message */ + public WebxdcMessageInfo getWebxdcInfo(Integer accountId, Integer instanceMsgId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_webxdc_info", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId)); + } + + /** + * Get href from a WebxdcInfoMessage which might include a hash holding + * information about a specific position or state in a webxdc app (optional) + */ + public String getWebxdcHref(Integer accountId, Integer infoMsgId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_webxdc_href", mapper.valueToTree(accountId), mapper.valueToTree(infoMsgId)); + } + + /** + * Get blob encoded as base64 from a webxdc message + *

+ * path is the path of the file within webxdc archive + */ + public String getWebxdcBlob(Integer accountId, Integer instanceMsgId, String path) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_webxdc_blob", mapper.valueToTree(accountId), mapper.valueToTree(instanceMsgId), mapper.valueToTree(path)); + } + + /** + * Sets Webxdc file as integration. + * `file` is the .xdc to use as Webxdc integration. + */ + public void setWebxdcIntegration(Integer accountId, String filePath) throws RpcException { + transport.call("set_webxdc_integration", mapper.valueToTree(accountId), mapper.valueToTree(filePath)); + } + + /** + * Returns Webxdc instance used for optional integrations. + * UI can open the Webxdc as usual. + * Returns `None` if there is no integration; the caller can add one using `set_webxdc_integration` then. + * `integrate_for` is the chat to get the integration for. + */ + public Integer initWebxdcIntegration(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "init_webxdc_integration", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /* Starts an outgoing call. */ + public Integer placeOutgoingCall(Integer accountId, Integer chatId, String placeCallInfo) throws RpcException { + return transport.callForResult(new TypeReference(){}, "place_outgoing_call", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(placeCallInfo)); + } + + /* Accepts an incoming call. */ + public void acceptIncomingCall(Integer accountId, Integer msgId, String acceptCallInfo) throws RpcException { + transport.call("accept_incoming_call", mapper.valueToTree(accountId), mapper.valueToTree(msgId), mapper.valueToTree(acceptCallInfo)); + } + + /* Ends incoming or outgoing call. */ + public void endCall(Integer accountId, Integer msgId) throws RpcException { + transport.call("end_call", mapper.valueToTree(accountId), mapper.valueToTree(msgId)); + } + + /* Returns information about the call. */ + public CallInfo callInfo(Integer accountId, Integer msgId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "call_info", mapper.valueToTree(accountId), mapper.valueToTree(msgId)); + } + + /* Returns JSON with ICE servers, to be used for WebRTC video calls. */ + public String iceServers(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "ice_servers", mapper.valueToTree(accountId)); + } + + /** + * Makes an HTTP GET request and returns a response. + *

+ * `url` is the HTTP or HTTPS URL. + */ + public HttpResponse getHttpResponse(Integer accountId, String url) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_http_response", mapper.valueToTree(accountId), mapper.valueToTree(url)); + } + + /** + * Forward messages to another chat. + *

+ * All types of messages can be forwarded, + * however, they will be flagged as such (dc_msg_is_forwarded() is set). + *

+ * Original sender, info-state and webxdc updates are not forwarded on purpose. + */ + public void forwardMessages(Integer accountId, java.util.List messageIds, Integer chatId) throws RpcException { + transport.call("forward_messages", mapper.valueToTree(accountId), mapper.valueToTree(messageIds), mapper.valueToTree(chatId)); + } + + /** + * Resend messages and make information available for newly added chat members. + * Resending sends out the original message, however, recipients and webxdc-status may differ. + * Clients that already have the original message can still ignore the resent message as + * they have tracked the state by dedicated updates. + *

+ * Some messages cannot be resent, eg. info-messages, drafts, already pending messages or messages that are not sent by SELF. + *

+ * message_ids all message IDs that should be resend. All messages must belong to the same chat. + */ + public void resendMessages(Integer accountId, java.util.List messageIds) throws RpcException { + transport.call("resend_messages", mapper.valueToTree(accountId), mapper.valueToTree(messageIds)); + } + + public Integer sendSticker(Integer accountId, Integer chatId, String stickerPath) throws RpcException { + return transport.callForResult(new TypeReference(){}, "send_sticker", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(stickerPath)); + } + + /** + * Send a reaction to message. + *

+ * Reaction is a string of emojis separated by spaces. Reaction to a + * single message can be sent multiple times. The last reaction + * received overrides all previously received reactions. It is + * possible to remove all reactions by sending an empty string. + */ + public Integer sendReaction(Integer accountId, Integer messageId, java.util.List reaction) throws RpcException { + return transport.callForResult(new TypeReference(){}, "send_reaction", mapper.valueToTree(accountId), mapper.valueToTree(messageId), mapper.valueToTree(reaction)); + } + + /* Returns reactions to the message. */ + public Reactions getMessageReactions(Integer accountId, Integer messageId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_message_reactions", mapper.valueToTree(accountId), mapper.valueToTree(messageId)); + } + + public Integer sendMsg(Integer accountId, Integer chatId, MessageData data) throws RpcException { + return transport.callForResult(new TypeReference(){}, "send_msg", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(data)); + } + + public void sendEditRequest(Integer accountId, Integer msgId, String newText) throws RpcException { + transport.call("send_edit_request", mapper.valueToTree(accountId), mapper.valueToTree(msgId), mapper.valueToTree(newText)); + } + + /* Checks if messages can be sent to a given chat. */ + public Boolean canSend(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "can_send", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /** + * Saves a file copy at the user-provided path. + *

+ * Fails if file already exists at the provided path. + */ + public void saveMsgFile(Integer accountId, Integer msgId, String path) throws RpcException { + transport.call("save_msg_file", mapper.valueToTree(accountId), mapper.valueToTree(msgId), mapper.valueToTree(path)); + } + + public void removeDraft(Integer accountId, Integer chatId) throws RpcException { + transport.call("remove_draft", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + /* Get draft for a chat, if any. */ + public Message getDraft(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "get_draft", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + + public String miscGetStickerFolder(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "misc_get_sticker_folder", mapper.valueToTree(accountId)); + } + + /* Saves a sticker to a collection/folder in the account's sticker folder. */ + public void miscSaveSticker(Integer accountId, Integer msgId, String collection) throws RpcException { + transport.call("misc_save_sticker", mapper.valueToTree(accountId), mapper.valueToTree(msgId), mapper.valueToTree(collection)); + } + + /** + * for desktop, get stickers from stickers folder, + * grouped by the collection/folder they are in. + */ + public java.util.Map> miscGetStickers(Integer accountId) throws RpcException { + return transport.callForResult(new TypeReference>>(){}, "misc_get_stickers", mapper.valueToTree(accountId)); + } + + /* Returns the messageid of the sent message */ + public Integer miscSendTextMessage(Integer accountId, Integer chatId, String text) throws RpcException { + return transport.callForResult(new TypeReference(){}, "misc_send_text_message", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(text)); + } + + /** + * Send a message to a chat. + *

+ * This function returns after the message has been placed in the sending queue. + * This does not imply that the message was really sent out yet. + * However, from your view, you're done with the message. + * Sooner or later it will find its way. + *

+ * **Attaching files:** + *

+ * Pass the file path in the `file` parameter. + * If `file` is not in the blob directory yet, + * it will be copied into the blob directory. + * If you want, you can delete the file immediately after this function returns. + *

+ * You can also write the attachment directly into the blob directory + * and then pass the path as the `file` parameter; + * this will prevent an unnecessary copying of the file. + *

+ * In `filename`, you can pass the original name of the file, + * which will then be shown in the UI. + * in this case the current name of `file` on the filesystem will be ignored. + *

+ * In order to deduplicate files that contain the same data, + * the file will be named `.`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`. + *

+ * NOTE: + * - This function will rename the file. To get the new file path, call `get_file()`. + * - The file must not be modified after this function was called. + * - Images etc. will NOT be recoded. + * In order to recode images, + * use `misc_set_draft` and pass `Image` as the viewtype. + */ + public Pair miscSendMsg(Integer accountId, Integer chatId, String text, String file, String filename, Pair location, Integer quotedMessageId) throws RpcException { + return transport.callForResult(new TypeReference>(){}, "misc_send_msg", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(text), mapper.valueToTree(file), mapper.valueToTree(filename), mapper.valueToTree(location), mapper.valueToTree(quotedMessageId)); + } + + public void miscSetDraft(Integer accountId, Integer chatId, String text, String file, String filename, Integer quotedMessageId, Viewtype viewType) throws RpcException { + transport.call("misc_set_draft", mapper.valueToTree(accountId), mapper.valueToTree(chatId), mapper.valueToTree(text), mapper.valueToTree(file), mapper.valueToTree(filename), mapper.valueToTree(quotedMessageId), mapper.valueToTree(viewType)); + } + + public Integer miscSendDraft(Integer accountId, Integer chatId) throws RpcException { + return transport.callForResult(new TypeReference(){}, "misc_send_draft", mapper.valueToTree(accountId), mapper.valueToTree(chatId)); + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/RpcException.java b/src/main/java/chat/delta/rpc/RpcException.java new file mode 100644 index 0000000000000000000000000000000000000000..6d165e387d5e92f4190a6c3977f13d3fb39d2949 --- /dev/null +++ b/src/main/java/chat/delta/rpc/RpcException.java @@ -0,0 +1,8 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc; + +public class RpcException extends Exception { + + public RpcException(String message) { super(message); } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Account.java b/src/main/java/chat/delta/rpc/types/Account.java new file mode 100644 index 0000000000000000000000000000000000000000..ab3a9778ff75f85c9a5e8d24ec297e45be97063b --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Account.java @@ -0,0 +1,32 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = Account.Configured.class, name="Configured"), @Type(value = Account.Unconfigured.class, name="Unconfigured")}) +public abstract class Account { + + public static class Configured extends Account { + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String addr; + public String color; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String displayName; + public Integer id; + /* Optional tag as "Work", "Family". Meant to help profile owner to differ between profiles with similar names. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String privateTag; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String profileImage; + } + + public static class Unconfigured extends Account { + public Integer id; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/BasicChat.java b/src/main/java/chat/delta/rpc/types/BasicChat.java new file mode 100644 index 0000000000000000000000000000000000000000..5ca3526fedc54657bd67b81b08fec721e9c2986d --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/BasicChat.java @@ -0,0 +1,35 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +/** + * cheaper version of fullchat, omits: - contacts - contact_ids - fresh_message_counter - ephemeral_timer - self_in_group - was_seen_recently - can_send + *

+ * used when you only need the basic metadata of a chat like type, name, profile picture + */ +public class BasicChat { + public Boolean archived; + public ChatType chatType; + public String color; + public Integer id; + public Boolean isContactRequest; + public Boolean isDeviceChat; + /** + * True if the chat is encrypted. This means that all messages in the chat are encrypted, and all contacts in the chat are "key-contacts", i.e. identified by the PGP key fingerprint. + *

+ * False if the chat is unencrypted. This means that all messages in the chat are unencrypted, and all contacts in the chat are "address-contacts", i.e. identified by the email address. The UI should mark this chat e.g. with a mail-letter icon. + *

+ * Unencrypted groups are called "ad-hoc groups" and the user can't add/remove members, create a QR invite code, or set an avatar. These options should therefore be disabled in the UI. + *

+ * Note that it can happen that an encrypted chat contains unencrypted messages that were received in core <= v1.159.* and vice versa. + *

+ * See also `is_key_contact` on `Contact`. + */ + public Boolean isEncrypted; + public Boolean isMuted; + public Boolean isSelfTalk; + public Boolean isUnpromoted; + public String name; + public Boolean pinned; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String profileImage; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/CallInfo.java b/src/main/java/chat/delta/rpc/types/CallInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..dd177c186c8456df0496bd0b0a9910deca274d92 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/CallInfo.java @@ -0,0 +1,19 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class CallInfo { + /* True if SDP offer has a video. */ + public Boolean hasVideo; + /** + * SDP offer. + *

+ * Can be used to manually answer the call even if incoming call event was missed. + */ + public String sdpOffer; + /** + * Call state. + *

+ * For example, if the call is accepted, active, canceled, declined etc. + */ + public CallState state; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/CallState.java b/src/main/java/chat/delta/rpc/types/CallState.java new file mode 100644 index 0000000000000000000000000000000000000000..b18577053f1d8a59d128216c6c329f0694c08131 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/CallState.java @@ -0,0 +1,48 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = CallState.Alerting.class, name="Alerting"), @Type(value = CallState.Active.class, name="Active"), @Type(value = CallState.Completed.class, name="Completed"), @Type(value = CallState.Missed.class, name="Missed"), @Type(value = CallState.Declined.class, name="Declined"), @Type(value = CallState.Canceled.class, name="Canceled")}) +public abstract class CallState { + +/** + * Fresh incoming or outgoing call that is still ringing. + *

+ * There is no separate state for outgoing call that has been dialled but not ringing on the other side yet as we don't know whether the other side received our call. + */ + public static class Alerting extends CallState { + } + +/* Active call. */ + public static class Active extends CallState { + } + +/* Completed call that was once active and then was terminated for any reason. */ + public static class Completed extends CallState { + /* Call duration in seconds. */ + public Integer duration; + } + +/* Incoming call that was not picked up within a timeout or was explicitly ended by the caller before we picked up. */ + public static class Missed extends CallState { + } + +/* Incoming call that was explicitly ended on our side before picking up or outgoing call that was declined before the timeout. */ + public static class Declined extends CallState { + } + +/** + * Outgoing call that has been canceled on our side before receiving a response. + *

+ * Incoming calls cannot be canceled, on the receiver side canceled calls usually result in missed calls. + */ + public static class Canceled extends CallState { + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/ChatListItemFetchResult.java b/src/main/java/chat/delta/rpc/types/ChatListItemFetchResult.java new file mode 100644 index 0000000000000000000000000000000000000000..687aa35be846c07ee6b7ff6110a391a38a921701 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/ChatListItemFetchResult.java @@ -0,0 +1,71 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = ChatListItemFetchResult.ChatListItem.class, name="ChatListItem"), @Type(value = ChatListItemFetchResult.ArchiveLink.class, name="ArchiveLink"), @Type(value = ChatListItemFetchResult.Error.class, name="Error")}) +public abstract class ChatListItemFetchResult { + + public static class ChatListItem extends ChatListItemFetchResult { + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String avatarPath; + public ChatType chatType; + public String color; + /* contact id if this is a dm chat (for view profile entry in context menu) */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer dmChatContact; + public Integer freshMessageCounter; + public Integer id; + public Boolean isArchived; + public Boolean isContactRequest; + public Boolean isDeviceTalk; + /** + * True if the chat is encrypted. This means that all messages in the chat are encrypted, and all contacts in the chat are "key-contacts", i.e. identified by the PGP key fingerprint. + *

+ * False if the chat is unencrypted. This means that all messages in the chat are unencrypted, and all contacts in the chat are "address-contacts", i.e. identified by the email address. The UI should mark this chat e.g. with a mail-letter icon. + *

+ * Unencrypted groups are called "ad-hoc groups" and the user can't add/remove members, create a QR invite code, or set an avatar. These options should therefore be disabled in the UI. + *

+ * Note that it can happen that an encrypted chat contains unencrypted messages that were received in core <= v1.159.* and vice versa. + *

+ * See also `is_key_contact` on `Contact`. + */ + public Boolean isEncrypted; + /* deprecated 2025-07, use chat_type instead */ + public Boolean isGroup; + public Boolean isMuted; + public Boolean isPinned; + public Boolean isSelfInGroup; + public Boolean isSelfTalk; + public Boolean isSendingLocation; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer lastMessageId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Viewtype lastMessageType; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer lastUpdated; + public String name; + /* showing preview if last chat message is image */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String summaryPreviewImage; + public Integer summaryStatus; + public String summaryText1; + public String summaryText2; + public Boolean wasSeenRecently; + } + + public static class ArchiveLink extends ChatListItemFetchResult { + public Integer freshMessageCounter; + } + + public static class Error extends ChatListItemFetchResult { + public String error; + public Integer id; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/ChatType.java b/src/main/java/chat/delta/rpc/types/ChatType.java new file mode 100644 index 0000000000000000000000000000000000000000..ef7d360e94c0ce4a40867b4578a247a091f73069 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/ChatType.java @@ -0,0 +1,10 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum ChatType { + Single, + Group, + Mailinglist, + OutBroadcast, + InBroadcast, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/ChatVisibility.java b/src/main/java/chat/delta/rpc/types/ChatVisibility.java new file mode 100644 index 0000000000000000000000000000000000000000..340595a61c979793676c93e3d8244302b8b2635d --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/ChatVisibility.java @@ -0,0 +1,8 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum ChatVisibility { + Normal, + Archived, + Pinned, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Contact.java b/src/main/java/chat/delta/rpc/types/Contact.java new file mode 100644 index 0000000000000000000000000000000000000000..91ed6d273f75c457b6fdd46bc10222717c08b1a2 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Contact.java @@ -0,0 +1,52 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class Contact { + public String address; + public String authName; + public String color; + public String displayName; + /** + * Is encryption available for this contact. + *

+ * This can only be true for key-contacts. However, it is possible to have a key-contact for which encryption is not available because we don't have a key yet, e.g. if we just scanned the fingerprint from a QR code. + */ + public Boolean e2eeAvail; + public Integer id; + public Boolean isBlocked; + /* If the contact is a bot. */ + public Boolean isBot; + /* Is the contact a key contact. */ + public Boolean isKeyContact; + /** + * True if the contact can be added to protected chats because SELF and contact have verified their fingerprints in both directions. + *

+ * See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information. + */ + public Boolean isVerified; + /* the contact's last seen timestamp */ + public Integer lastSeen; + public String name; + public String nameAndAddr; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String profileImage; + public String status; + /** + * The contact ID that verified a contact. + *

+ * As verifier may be unknown, use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat. + *

+ * UI should display the information in the contact's profile as follows: + *

+ * - If `verifierId` != 0, display text "Introduced by ..." with the name and address of the contact formatted by `name_and_addr`/`nameAndAddr`. Prefix the text by a green checkmark. + *

+ * - If `verifierId` == 0 and `isVerified` != 0, display "Introduced" prefixed by a green checkmark. + *

+ * - if `verifierId` == 0 and `isVerified` == 0, display nothing + *

+ * This contains the contact ID of the verifier. If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself. If it is None/Null, we don't have verifier information or the contact is not verified. + */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer verifierId; + public Boolean wasSeenRecently; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/DownloadState.java b/src/main/java/chat/delta/rpc/types/DownloadState.java new file mode 100644 index 0000000000000000000000000000000000000000..4a8cbad3ce0d6f9de4e53a2bc3b8c3366ffdf6b6 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/DownloadState.java @@ -0,0 +1,10 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum DownloadState { + Done, + Available, + Failure, + Undecipherable, + InProgress, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/EnteredCertificateChecks.java b/src/main/java/chat/delta/rpc/types/EnteredCertificateChecks.java new file mode 100644 index 0000000000000000000000000000000000000000..77c8f9f850245cf15fae20b4a4c543731170d9cf --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/EnteredCertificateChecks.java @@ -0,0 +1,13 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum EnteredCertificateChecks { + /* `Automatic` means that provider database setting should be taken. If there is no provider database setting for certificate checks, check certificates strictly. */ + automatic, + + /* Ensure that TLS certificate is valid for the server hostname. */ + strict, + + /* Accept certificates that are expired, self-signed or otherwise not valid for the server hostname. */ + acceptInvalidCertificates, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/EnteredLoginParam.java b/src/main/java/chat/delta/rpc/types/EnteredLoginParam.java new file mode 100644 index 0000000000000000000000000000000000000000..87c1c94271466051264d266e97b843326330b90a --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/EnteredLoginParam.java @@ -0,0 +1,51 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +/** + * Login parameters entered by the user. + *

+ * Usually it will be enough to only set `addr` and `password`, and all the other settings will be autoconfigured. + */ +public class EnteredLoginParam { + /* Email address. */ + public String addr; + /* TLS options: whether to allow invalid certificates and/or invalid hostnames. Default: Automatic */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public EnteredCertificateChecks certificateChecks; + /* Imap server port. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer imapPort; + /* Imap socket security. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Socket imapSecurity; + /* Imap server hostname or IP address. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String imapServer; + /* Imap username. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String imapUser; + /* If true, login via OAUTH2 (not recommended anymore). Default: false */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Boolean oauth2; + /* Password. */ + public String password; + /** + * SMTP Password. + *

+ * Only needs to be specified if different than IMAP password. + */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String smtpPassword; + /* SMTP server port. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer smtpPort; + /* SMTP socket security. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Socket smtpSecurity; + /* SMTP server hostname or IP address. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String smtpServer; + /* SMTP username. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String smtpUser; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/EphemeralTimer.java b/src/main/java/chat/delta/rpc/types/EphemeralTimer.java new file mode 100644 index 0000000000000000000000000000000000000000..fb4e34f72cceb9dbf36858a6580468c8ae2651cb --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/EphemeralTimer.java @@ -0,0 +1,28 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = EphemeralTimer.Disabled.class, name="Disabled"), @Type(value = EphemeralTimer.Enabled.class, name="Enabled")}) +public abstract class EphemeralTimer { + +/* Timer is disabled. */ + public static class Disabled extends EphemeralTimer { + } + +/* Timer is enabled. */ + public static class Enabled extends EphemeralTimer { + /** + * Timer duration in seconds. + *

+ * The value cannot be 0. + */ + public Integer duration; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Event.java b/src/main/java/chat/delta/rpc/types/Event.java new file mode 100644 index 0000000000000000000000000000000000000000..0d23609d74cfa36b97e28adf34495e59483b934e --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Event.java @@ -0,0 +1,9 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class Event { + /* Account ID. */ + public Integer contextId; + /* Event payload. */ + public EventType event; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/EventType.java b/src/main/java/chat/delta/rpc/types/EventType.java new file mode 100644 index 0000000000000000000000000000000000000000..04c80f632e36f5372172d391efd68ca8da4abab8 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/EventType.java @@ -0,0 +1,420 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = EventType.Info.class, name="Info"), @Type(value = EventType.SmtpConnected.class, name="SmtpConnected"), @Type(value = EventType.ImapConnected.class, name="ImapConnected"), @Type(value = EventType.SmtpMessageSent.class, name="SmtpMessageSent"), @Type(value = EventType.ImapMessageDeleted.class, name="ImapMessageDeleted"), @Type(value = EventType.ImapMessageMoved.class, name="ImapMessageMoved"), @Type(value = EventType.ImapInboxIdle.class, name="ImapInboxIdle"), @Type(value = EventType.NewBlobFile.class, name="NewBlobFile"), @Type(value = EventType.DeletedBlobFile.class, name="DeletedBlobFile"), @Type(value = EventType.Warning.class, name="Warning"), @Type(value = EventType.Error.class, name="Error"), @Type(value = EventType.ErrorSelfNotInGroup.class, name="ErrorSelfNotInGroup"), @Type(value = EventType.MsgsChanged.class, name="MsgsChanged"), @Type(value = EventType.ReactionsChanged.class, name="ReactionsChanged"), @Type(value = EventType.IncomingReaction.class, name="IncomingReaction"), @Type(value = EventType.IncomingWebxdcNotify.class, name="IncomingWebxdcNotify"), @Type(value = EventType.IncomingMsg.class, name="IncomingMsg"), @Type(value = EventType.IncomingMsgBunch.class, name="IncomingMsgBunch"), @Type(value = EventType.MsgsNoticed.class, name="MsgsNoticed"), @Type(value = EventType.MsgDelivered.class, name="MsgDelivered"), @Type(value = EventType.MsgFailed.class, name="MsgFailed"), @Type(value = EventType.MsgRead.class, name="MsgRead"), @Type(value = EventType.MsgDeleted.class, name="MsgDeleted"), @Type(value = EventType.ChatModified.class, name="ChatModified"), @Type(value = EventType.ChatEphemeralTimerModified.class, name="ChatEphemeralTimerModified"), @Type(value = EventType.ChatDeleted.class, name="ChatDeleted"), @Type(value = EventType.ContactsChanged.class, name="ContactsChanged"), @Type(value = EventType.LocationChanged.class, name="LocationChanged"), @Type(value = EventType.ConfigureProgress.class, name="ConfigureProgress"), @Type(value = EventType.ImexProgress.class, name="ImexProgress"), @Type(value = EventType.ImexFileWritten.class, name="ImexFileWritten"), @Type(value = EventType.SecurejoinInviterProgress.class, name="SecurejoinInviterProgress"), @Type(value = EventType.SecurejoinJoinerProgress.class, name="SecurejoinJoinerProgress"), @Type(value = EventType.ConnectivityChanged.class, name="ConnectivityChanged"), @Type(value = EventType.SelfavatarChanged.class, name="SelfavatarChanged"), @Type(value = EventType.ConfigSynced.class, name="ConfigSynced"), @Type(value = EventType.WebxdcStatusUpdate.class, name="WebxdcStatusUpdate"), @Type(value = EventType.WebxdcRealtimeData.class, name="WebxdcRealtimeData"), @Type(value = EventType.WebxdcRealtimeAdvertisementReceived.class, name="WebxdcRealtimeAdvertisementReceived"), @Type(value = EventType.WebxdcInstanceDeleted.class, name="WebxdcInstanceDeleted"), @Type(value = EventType.AccountsBackgroundFetchDone.class, name="AccountsBackgroundFetchDone"), @Type(value = EventType.ChatlistChanged.class, name="ChatlistChanged"), @Type(value = EventType.ChatlistItemChanged.class, name="ChatlistItemChanged"), @Type(value = EventType.AccountsChanged.class, name="AccountsChanged"), @Type(value = EventType.AccountsItemChanged.class, name="AccountsItemChanged"), @Type(value = EventType.EventChannelOverflow.class, name="EventChannelOverflow"), @Type(value = EventType.IncomingCall.class, name="IncomingCall"), @Type(value = EventType.IncomingCallAccepted.class, name="IncomingCallAccepted"), @Type(value = EventType.OutgoingCallAccepted.class, name="OutgoingCallAccepted"), @Type(value = EventType.CallEnded.class, name="CallEnded"), @Type(value = EventType.TransportsModified.class, name="TransportsModified")}) +public abstract class EventType { + +/** + * The library-user may write an informational string to the log. + *

+ * This event should *not* be reported to the end-user using a popup or something like that. + */ + public static class Info extends EventType { + public String msg; + } + +/* Emitted when SMTP connection is established and login was successful. */ + public static class SmtpConnected extends EventType { + public String msg; + } + +/* Emitted when IMAP connection is established and login was successful. */ + public static class ImapConnected extends EventType { + public String msg; + } + +/* Emitted when a message was successfully sent to the SMTP server. */ + public static class SmtpMessageSent extends EventType { + public String msg; + } + +/* Emitted when an IMAP message has been marked as deleted */ + public static class ImapMessageDeleted extends EventType { + public String msg; + } + +/* Emitted when an IMAP message has been moved */ + public static class ImapMessageMoved extends EventType { + public String msg; + } + +/* Emitted before going into IDLE on the Inbox folder. */ + public static class ImapInboxIdle extends EventType { + } + +/* Emitted when an new file in the $BLOBDIR was created */ + public static class NewBlobFile extends EventType { + public String file; + } + +/* Emitted when an file in the $BLOBDIR was deleted */ + public static class DeletedBlobFile extends EventType { + public String file; + } + +/** + * The library-user should write a warning string to the log. + *

+ * This event should *not* be reported to the end-user using a popup or something like that. + */ + public static class Warning extends EventType { + public String msg; + } + +/** + * The library-user should report an error to the end-user. + *

+ * As most things are asynchronous, things may go wrong at any time and the user should not be disturbed by a dialog or so. Instead, use a bubble or so. + *

+ * However, for ongoing processes (eg. configure()) or for functions that are expected to fail (eg. autocryptContinueKeyTransfer()) it might be better to delay showing these events until the function has really failed (returned false). It should be sufficient to report only the *last* error in a message box then. + */ + public static class Error extends EventType { + public String msg; + } + +/* An action cannot be performed because the user is not in the group. Reported eg. after a call to setChatName(), setChatProfileImage(), addContactToChat(), removeContactFromChat(), and messages sending functions. */ + public static class ErrorSelfNotInGroup extends EventType { + public String msg; + } + +/* Messages or chats changed. One or more messages or chats changed for various reasons in the database: - Messages sent, received or removed - Chats created, deleted or archived - A draft has been set */ + public static class MsgsChanged extends EventType { + /* Set if only a single chat is affected by the changes, otherwise 0. */ + public Integer chatId; + /* Set if only a single message is affected by the changes, otherwise 0. */ + public Integer msgId; + } + +/* Reactions for the message changed. */ + public static class ReactionsChanged extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chatId; + /* ID of the contact whose reaction set is changed. */ + public Integer contactId; + /* ID of the message for which reactions were changed. */ + public Integer msgId; + } + +/** + * A reaction to one's own sent message received. Typically, the UI will show a notification for that. + *

+ * In addition to this event, ReactionsChanged is emitted. + */ + public static class IncomingReaction extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chatId; + /* ID of the contact whose reaction set is changed. */ + public Integer contactId; + /* ID of the message for which reactions were changed. */ + public Integer msgId; + /* The reaction. */ + public String reaction; + } + +/* Incoming webxdc info or summary update, should be notified. */ + public static class IncomingWebxdcNotify extends EventType { + /* ID of the chat. */ + public Integer chatId; + /* ID of the contact sending. */ + public Integer contactId; + /* Link assigned to this notification, if any. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String href; + /* ID of the added info message or webxdc instance in case of summary change. */ + public Integer msgId; + /* Text to notify. */ + public String text; + } + +/** + * There is a fresh message. Typically, the user will show a notification when receiving this message. + *

+ * There is no extra #DC_EVENT_MSGS_CHANGED event sent together with this event. + */ + public static class IncomingMsg extends EventType { + /* ID of the chat where the message is assigned. */ + public Integer chatId; + /* ID of the message. */ + public Integer msgId; + } + +/* Downloading a bunch of messages just finished. This is an event to allow the UI to only show one notification per message bunch, instead of cluttering the user with many notifications. */ + public static class IncomingMsgBunch extends EventType { + } + +/* Messages were seen or noticed. chat id is always set. */ + public static class MsgsNoticed extends EventType { + public Integer chatId; + } + +/* A single message is sent successfully. State changed from DC_STATE_OUT_PENDING to DC_STATE_OUT_DELIVERED, see `Message.state`. */ + public static class MsgDelivered extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chatId; + /* ID of the message that was successfully sent. */ + public Integer msgId; + } + +/* A single message could not be sent. State changed from DC_STATE_OUT_PENDING or DC_STATE_OUT_DELIVERED to DC_STATE_OUT_FAILED, see `Message.state`. */ + public static class MsgFailed extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chatId; + /* ID of the message that could not be sent. */ + public Integer msgId; + } + +/* A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to DC_STATE_OUT_MDN_RCVD, see `Message.state`. */ + public static class MsgRead extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chatId; + /* ID of the message that was read. */ + public Integer msgId; + } + +/** + * A single message was deleted. + *

+ * This event means that the message will no longer appear in the messagelist. UI should remove the message from the messagelist in response to this event if the message is currently displayed. + *

+ * The message may have been explicitly deleted by the user or expired. Internally the message may have been removed from the database, moved to the trash chat or hidden. + *

+ * This event does not indicate the message deletion from the server. + */ + public static class MsgDeleted extends EventType { + /* ID of the chat where the message was prior to deletion. Never 0. */ + public Integer chatId; + /* ID of the deleted message. Never 0. */ + public Integer msgId; + } + +/** + * Chat changed. The name or the image of a chat group was changed or members were added or removed. See setChatName(), setChatProfileImage(), addContactToChat() and removeContactFromChat(). + *

+ * This event does not include ephemeral timer modification, which is a separate event. + */ + public static class ChatModified extends EventType { + public Integer chatId; + } + +/* Chat ephemeral timer changed. */ + public static class ChatEphemeralTimerModified extends EventType { + /* Chat ID. */ + public Integer chatId; + /* New ephemeral timer value. */ + public Integer timer; + } + +/* Chat deleted. */ + public static class ChatDeleted extends EventType { + /* Chat ID. */ + public Integer chat_id; + } + +/* Contact(s) created, renamed, blocked or deleted. */ + public static class ContactsChanged extends EventType { + /* If set, this is the contact_id of an added contact that should be selected. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer contactId; + } + +/* Location of one or more contact has changed. */ + public static class LocationChanged extends EventType { + /* contact_id of the contact for which the location has changed. If the locations of several contacts have been changed, this parameter is set to `None`. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer contactId; + } + +/* Inform about the configuration progress started by configure(). */ + public static class ConfigureProgress extends EventType { + /* Progress comment or error, something to display to the user. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String comment; + /** + * Progress. + *

+ * 0=error, 1-999=progress in permille, 1000=success and done + */ + public Integer progress; + } + +/* Inform about the import/export progress started by imex(). */ + public static class ImexProgress extends EventType { + /* 0=error, 1-999=progress in permille, 1000=success and done */ + public Integer progress; + } + +/** + * A file has been exported. A file has been written by imex(). This event may be sent multiple times by a single call to imex(). + *

+ * A typical purpose for a handler of this event may be to make the file public to some system services. + *

+ * @param data2 0 + */ + public static class ImexFileWritten extends EventType { + public String path; + } + +/** + * Progress event sent when SecureJoin protocol has finished from the view of the inviter (Alice, the person who shows the QR code). + *

+ * These events are typically sent after a joiner has scanned the QR code generated by getChatSecurejoinQrCodeSvg(). + */ + public static class SecurejoinInviterProgress extends EventType { + /* ID of the chat in case of success. */ + public Integer chatId; + /* The type of the joined chat. This can take the same values as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]). */ + public ChatType chatType; + /* ID of the contact that wants to join. */ + public Integer contactId; + /* Progress, always 1000. */ + public Integer progress; + } + +/* Progress information of a secure-join handshake from the view of the joiner (Bob, the person who scans the QR code). The events are typically sent while secureJoin(), which may take some time, is executed. */ + public static class SecurejoinJoinerProgress extends EventType { + /* ID of the inviting contact. */ + public Integer contactId; + /* Progress as: 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself." (Bob has verified alice and waits until Alice does the same for him) 1000=vg-member-added/vc-contact-confirm received */ + public Integer progress; + } + +/* The connectivity to the server changed. This means that you should refresh the connectivity view and possibly the connectivtiy HTML; see getConnectivity() and getConnectivityHtml() for details. */ + public static class ConnectivityChanged extends EventType { + } + +/* Deprecated by `ConfigSynced`. */ + public static class SelfavatarChanged extends EventType { + } + +/* A multi-device synced config value changed. Maybe the app needs to refresh smth. For uniformity this is emitted on the source device too. The value isn't here, otherwise it would be logged which might not be good for privacy. */ + public static class ConfigSynced extends EventType { + /* Configuration key. */ + public String key; + } + + public static class WebxdcStatusUpdate extends EventType { + /* Message ID. */ + public Integer msgId; + /* Status update ID. */ + public Integer statusUpdateSerial; + } + +/* Data received over an ephemeral peer channel. */ + public static class WebxdcRealtimeData extends EventType { + /* Realtime data. */ + public java.util.List data; + /* Message ID. */ + public Integer msgId; + } + +/* Advertisement received over an ephemeral peer channel. This can be used by bots to initiate peer-to-peer communication from their side. */ + public static class WebxdcRealtimeAdvertisementReceived extends EventType { + /* Message ID of the webxdc instance. */ + public Integer msgId; + } + +/* Inform that a message containing a webxdc instance has been deleted */ + public static class WebxdcInstanceDeleted extends EventType { + /* ID of the deleted message. */ + public Integer msgId; + } + +/** + * Tells that the Background fetch was completed (or timed out). This event acts as a marker, when you reach this event you can be sure that all events emitted during the background fetch were processed. + *

+ * This event is only emitted by the account manager + */ + public static class AccountsBackgroundFetchDone extends EventType { + } + +/** + * Inform that set of chats or the order of the chats in the chatlist has changed. + *

+ * Sometimes this is emitted together with `UIChatlistItemChanged`. + */ + public static class ChatlistChanged extends EventType { + } + +/* Inform that a single chat list item changed and needs to be rerendered. If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache. */ + public static class ChatlistItemChanged extends EventType { + /* ID of the changed chat */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer chatId; + } + +/** + * Inform that the list of accounts has changed (an account removed or added or (not yet implemented) the account order changes) + *

+ * This event is only emitted by the account manager + */ + public static class AccountsChanged extends EventType { + } + +/** + * Inform that an account property that might be shown in the account list changed, namely: - is_configured (see is_configured()) - displayname - selfavatar - private_tag + *

+ * This event is emitted from the account whose property changed. + */ + public static class AccountsItemChanged extends EventType { + } + +/* Inform than some events have been skipped due to event channel overflow. */ + public static class EventChannelOverflow extends EventType { + /* Number of events skipped. */ + public Integer n; + } + +/* Incoming call. */ + public static class IncomingCall extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chat_id; + /* True if incoming call is a video call. */ + public Boolean has_video; + /* ID of the info message referring to the call. */ + public Integer msg_id; + /* User-defined info as passed to place_outgoing_call() */ + public String place_call_info; + } + +/* Incoming call accepted. This is esp. interesting to stop ringing on other devices. */ + public static class IncomingCallAccepted extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chat_id; + /* ID of the info message referring to the call. */ + public Integer msg_id; + } + +/* Outgoing call accepted. */ + public static class OutgoingCallAccepted extends EventType { + /* User-defined info passed to dc_accept_incoming_call( */ + public String accept_call_info; + /* ID of the chat which the message belongs to. */ + public Integer chat_id; + /* ID of the info message referring to the call. */ + public Integer msg_id; + } + +/* Call ended. */ + public static class CallEnded extends EventType { + /* ID of the chat which the message belongs to. */ + public Integer chat_id; + /* ID of the info message referring to the call. */ + public Integer msg_id; + } + +/** + * One or more transports has changed. + *

+ * This event is used for tests to detect when transport synchronization messages arrives. UIs don't need to use it, it is unlikely that user modifies transports on multiple devices simultaneously. + */ + public static class TransportsModified extends EventType { + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/FullChat.java b/src/main/java/chat/delta/rpc/types/FullChat.java new file mode 100644 index 0000000000000000000000000000000000000000..141a57b02e141051111778779a4df08e010e5484 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/FullChat.java @@ -0,0 +1,42 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class FullChat { + public Boolean archived; + public Boolean canSend; + public ChatType chatType; + public String color; + public java.util.List contactIds; + public java.util.List contacts; + public Integer ephemeralTimer; + public Integer freshMessageCounter; + public Integer id; + public Boolean isContactRequest; + public Boolean isDeviceChat; + /** + * True if the chat is encrypted. This means that all messages in the chat are encrypted, and all contacts in the chat are "key-contacts", i.e. identified by the PGP key fingerprint. + *

+ * False if the chat is unencrypted. This means that all messages in the chat are unencrypted, and all contacts in the chat are "address-contacts", i.e. identified by the email address. The UI should mark this chat e.g. with a mail-letter icon. + *

+ * Unencrypted groups are called "ad-hoc groups" and the user can't add/remove members, create a QR invite code, or set an avatar. These options should therefore be disabled in the UI. + *

+ * Note that it can happen that an encrypted chat contains unencrypted messages that were received in core <= v1.159.* and vice versa. + *

+ * See also `is_key_contact` on `Contact`. + */ + public Boolean isEncrypted; + public Boolean isMuted; + public Boolean isSelfTalk; + public Boolean isUnpromoted; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String mailingListAddress; + public String name; + /* Contact IDs of the past chat members. */ + public java.util.List pastContactIds; + public Boolean pinned; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String profileImage; + /* Note that this is different from [`ChatListItem::is_self_in_group`](`crate::api::types::chat_list::ChatListItemFetchResult::ChatListItem::is_self_in_group`). This property should only be accessed when [`FullChat::chat_type`] is [`Chattype::Group`]. */ + public Boolean selfInGroup; + public Boolean wasSeenRecently; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/HttpResponse.java b/src/main/java/chat/delta/rpc/types/HttpResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..8ad3c64384d0e695b68ad46cfd9293860effa0a1 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/HttpResponse.java @@ -0,0 +1,13 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class HttpResponse { + /* base64-encoded response body. */ + public String blob; + /* Encoding, e.g. "utf-8". */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String encoding; + /* MIME type, e.g. "text/plain" or "text/html". */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String mimetype; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Location.java b/src/main/java/chat/delta/rpc/types/Location.java new file mode 100644 index 0000000000000000000000000000000000000000..f7c4b4554b95973ce512de026087300b45cdbe2f --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Location.java @@ -0,0 +1,16 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class Location { + public Float accuracy; + public Integer chatId; + public Integer contactId; + public Boolean isIndependent; + public Float latitude; + public Integer locationId; + public Float longitude; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String marker; + public Integer msgId; + public Integer timestamp; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Message.java b/src/main/java/chat/delta/rpc/types/Message.java new file mode 100644 index 0000000000000000000000000000000000000000..498767896c2f89601aeef40c5fd22cb702c1da65 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Message.java @@ -0,0 +1,69 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class Message { + public Integer chatId; + public Integer dimensionsHeight; + public Integer dimensionsWidth; + public DownloadState downloadState; + public Integer duration; + /* An error text, if there is one. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String error; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String file; + public Integer fileBytes; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String fileMime; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String fileName; + public Integer fromId; + public Boolean hasDeviatingTimestamp; + public Boolean hasHtml; + /* Check if a message has a POI location bound to it. These locations are also returned by `get_locations` method. The UI may decide to display a special icon beside such messages. */ + public Boolean hasLocation; + public Integer id; + /* if is_info is set, this refers to the contact profile that should be opened when the info message is tapped. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer infoContactId; + /* True if the message was sent by a bot. */ + public Boolean isBot; + public Boolean isEdited; + public Boolean isForwarded; + public Boolean isInfo; + public Boolean isSetupmessage; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer originalMsgId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String overrideSenderName; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer parentId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public MessageQuote quote; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Reactions reactions; + public Integer receivedTimestamp; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer savedMessageId; + public Contact sender; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String setupCodeBegin; + /** + * True if the message was correctly encrypted&signed, false otherwise. Historically, UIs showed a small padlock on the message then. + *

+ * Today, the UIs should instead show a small email-icon on the message if `show_padlock` is `false`, and nothing if it is `true`. + */ + public Boolean showPadlock; + public Integer sortTimestamp; + public Integer state; + public String subject; + /* when is_info is true this describes what type of system message it is */ + public SystemMessageType systemMessageType; + public String text; + public Integer timestamp; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public VcardContact vcardContact; + public Viewtype viewType; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String webxdcHref; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageData.java b/src/main/java/chat/delta/rpc/types/MessageData.java new file mode 100644 index 0000000000000000000000000000000000000000..647b586e0594228c5361684be65cdcc7bfcd804e --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageData.java @@ -0,0 +1,24 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class MessageData { + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String file; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String filename; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String html; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Pair location; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String overrideSenderName; + /* Quoted message id. Takes preference over `quoted_text` (see below). */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer quotedMessageId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String quotedText; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String text; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Viewtype viewtype; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageInfo.java b/src/main/java/chat/delta/rpc/types/MessageInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..c01228d5cbd6fcef4d0ea9de5947319051a3c4a1 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageInfo.java @@ -0,0 +1,14 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class MessageInfo { + public EphemeralTimer ephemeralTimer; + /* When message is ephemeral this contains the timestamp of the message expiry */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer ephemeralTimestamp; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String error; + public String hopInfo; + public String rfc724Mid; + public java.util.List serverUrls; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageListItem.java b/src/main/java/chat/delta/rpc/types/MessageListItem.java new file mode 100644 index 0000000000000000000000000000000000000000..666152e0f76672695eaabba14d55f4e2431ac1ae --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageListItem.java @@ -0,0 +1,24 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = MessageListItem.Message.class, name="Message"), @Type(value = MessageListItem.DayMarker.class, name="DayMarker")}) +public abstract class MessageListItem { + + public static class Message extends MessageListItem { + public Integer msg_id; + } + +/* Day marker, separating messages that correspond to different days according to local time. */ + public static class DayMarker extends MessageListItem { + /* Marker timestamp, for day markers, in unix milliseconds */ + public Integer timestamp; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageLoadResult.java b/src/main/java/chat/delta/rpc/types/MessageLoadResult.java new file mode 100644 index 0000000000000000000000000000000000000000..5ad3397512c614cfa91b8102b47983b82b65f0db --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageLoadResult.java @@ -0,0 +1,85 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = MessageLoadResult.Message.class, name="Message"), @Type(value = MessageLoadResult.LoadingError.class, name="LoadingError")}) +public abstract class MessageLoadResult { + + public static class Message extends MessageLoadResult { + public Integer chatId; + public Integer dimensionsHeight; + public Integer dimensionsWidth; + public DownloadState downloadState; + public Integer duration; + /* An error text, if there is one. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String error; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String file; + public Integer fileBytes; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String fileMime; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String fileName; + public Integer fromId; + public Boolean hasDeviatingTimestamp; + public Boolean hasHtml; + /* Check if a message has a POI location bound to it. These locations are also returned by `get_locations` method. The UI may decide to display a special icon beside such messages. */ + public Boolean hasLocation; + public Integer id; + /* if is_info is set, this refers to the contact profile that should be opened when the info message is tapped. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer infoContactId; + /* True if the message was sent by a bot. */ + public Boolean isBot; + public Boolean isEdited; + public Boolean isForwarded; + public Boolean isInfo; + public Boolean isSetupmessage; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer originalMsgId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String overrideSenderName; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer parentId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public MessageQuote quote; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Reactions reactions; + public Integer receivedTimestamp; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer savedMessageId; + public Contact sender; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String setupCodeBegin; + /** + * True if the message was correctly encrypted&signed, false otherwise. Historically, UIs showed a small padlock on the message then. + *

+ * Today, the UIs should instead show a small email-icon on the message if `show_padlock` is `false`, and nothing if it is `true`. + */ + public Boolean showPadlock; + public Integer sortTimestamp; + public Integer state; + public String subject; + /* when is_info is true this describes what type of system message it is */ + public SystemMessageType systemMessageType; + public String text; + public Integer timestamp; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public VcardContact vcardContact; + public Viewtype viewType; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String webxdcHref; + } + + public static class LoadingError extends MessageLoadResult { + public String error; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageNotificationInfo.java b/src/main/java/chat/delta/rpc/types/MessageNotificationInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..c8b6669b38689a526dc688dd42100618ac745c98 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageNotificationInfo.java @@ -0,0 +1,20 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class MessageNotificationInfo { + public Integer accountId; + public Integer chatId; + public String chatName; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String chatProfileImage; + public Integer id; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String image; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String imageMimeType; + /* also known as summary_text1 */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String summaryPrefix; + /* also known as summary_text2 */ + public String summaryText; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageQuote.java b/src/main/java/chat/delta/rpc/types/MessageQuote.java new file mode 100644 index 0000000000000000000000000000000000000000..3a11ee806cb1ebacdecf99959a45cb84f3ec9a56 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageQuote.java @@ -0,0 +1,33 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = MessageQuote.JustText.class, name="JustText"), @Type(value = MessageQuote.WithMessage.class, name="WithMessage")}) +public abstract class MessageQuote { + + public static class JustText extends MessageQuote { + public String text; + } + + public static class WithMessage extends MessageQuote { + public String authorDisplayColor; + public String authorDisplayName; + /* The quoted message does not always belong to the same chat, e.g. when "Reply Privately" is used. */ + public Integer chatId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String image; + public Boolean isForwarded; + public Integer messageId; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String overrideSenderName; + public String text; + public Viewtype viewType; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageReadReceipt.java b/src/main/java/chat/delta/rpc/types/MessageReadReceipt.java new file mode 100644 index 0000000000000000000000000000000000000000..88c4e1908bae8bf75f079d8df27f21bfa8ee3bd9 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageReadReceipt.java @@ -0,0 +1,7 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class MessageReadReceipt { + public Integer contactId; + public Integer timestamp; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MessageSearchResult.java b/src/main/java/chat/delta/rpc/types/MessageSearchResult.java new file mode 100644 index 0000000000000000000000000000000000000000..d5caadc74bca01db31f3b6de8cc087fd1b489488 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MessageSearchResult.java @@ -0,0 +1,22 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class MessageSearchResult { + public String authorColor; + public Integer authorId; + /* if sender name if overridden it will show it as ~alias */ + public String authorName; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String authorProfileImage; + public String chatColor; + public Integer chatId; + public String chatName; + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String chatProfileImage; + public ChatType chatType; + public Integer id; + public Boolean isChatArchived; + public Boolean isChatContactRequest; + public String message; + public Integer timestamp; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/MuteDuration.java b/src/main/java/chat/delta/rpc/types/MuteDuration.java new file mode 100644 index 0000000000000000000000000000000000000000..88363bfbb41e847fdd6b274f019e511b07a9381e --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/MuteDuration.java @@ -0,0 +1,24 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = MuteDuration.NotMuted.class, name="NotMuted"), @Type(value = MuteDuration.Forever.class, name="Forever"), @Type(value = MuteDuration.Until.class, name="Until")}) +public abstract class MuteDuration { + + public static class NotMuted extends MuteDuration { + } + + public static class Forever extends MuteDuration { + } + + public static class Until extends MuteDuration { + public Integer duration; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/NotifyState.java b/src/main/java/chat/delta/rpc/types/NotifyState.java new file mode 100644 index 0000000000000000000000000000000000000000..41f2ea388e473297ca003b2c50a4b4b68a0a45b4 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/NotifyState.java @@ -0,0 +1,13 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum NotifyState { + /* Not subscribed to push notifications. */ + NotConnected, + + /* Subscribed to heartbeat push notifications. */ + Heartbeat, + + /* Subscribed to push notifications for new messages. */ + Connected, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Pair.java b/src/main/java/chat/delta/rpc/types/Pair.java new file mode 100644 index 0000000000000000000000000000000000000000..141be5c22292825df60d259ad4e93a60fa12b612 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Pair.java @@ -0,0 +1,36 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class Pair { + private final T1 v1; + private final T2 v2; + + public Pair(T1 v1, T2 v2) { + this.v1 = v1; + this.v2 = v2; + } + + public T1 first(){ + return v1; + } + + public T2 second(){ + return v2; + } + + public boolean equals(Object o) { + return o instanceof Pair && + equal(((Pair) o).first(), first()) && + equal(((Pair) o).second(), second()); + } + + public int hashCode() { + return first().hashCode() ^ second().hashCode(); + } + + private boolean equal(Object first, Object second) { + if (first == null && second == null) return true; + if (first == null || second == null) return false; + return first.equals(second); + } +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/ProviderInfo.java b/src/main/java/chat/delta/rpc/types/ProviderInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..d950cc0d29f48b1242e672d726a9532d0fcebf3f --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/ProviderInfo.java @@ -0,0 +1,10 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class ProviderInfo { + public String beforeLoginHint; + /* Unique ID, corresponding to provider database filename. */ + public String id; + public String overviewPage; + public Integer status; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Qr.java b/src/main/java/chat/delta/rpc/types/Qr.java new file mode 100644 index 0000000000000000000000000000000000000000..902b934313e57a7dcfffb4e57d5bf451ae969579 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Qr.java @@ -0,0 +1,254 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="kind") +@JsonSubTypes({@Type(value = Qr.AskVerifyContact.class, name="AskVerifyContact"), @Type(value = Qr.AskVerifyGroup.class, name="AskVerifyGroup"), @Type(value = Qr.AskJoinBroadcast.class, name="AskJoinBroadcast"), @Type(value = Qr.FprOk.class, name="FprOk"), @Type(value = Qr.FprMismatch.class, name="FprMismatch"), @Type(value = Qr.FprWithoutAddr.class, name="FprWithoutAddr"), @Type(value = Qr.Account.class, name="Account"), @Type(value = Qr.Backup2.class, name="Backup2"), @Type(value = Qr.BackupTooNew.class, name="BackupTooNew"), @Type(value = Qr.WebrtcInstance.class, name="WebrtcInstance"), @Type(value = Qr.Proxy.class, name="Proxy"), @Type(value = Qr.Addr.class, name="Addr"), @Type(value = Qr.Url.class, name="Url"), @Type(value = Qr.Text.class, name="Text"), @Type(value = Qr.WithdrawVerifyContact.class, name="WithdrawVerifyContact"), @Type(value = Qr.WithdrawVerifyGroup.class, name="WithdrawVerifyGroup"), @Type(value = Qr.WithdrawJoinBroadcast.class, name="WithdrawJoinBroadcast"), @Type(value = Qr.ReviveVerifyContact.class, name="ReviveVerifyContact"), @Type(value = Qr.ReviveVerifyGroup.class, name="ReviveVerifyGroup"), @Type(value = Qr.ReviveJoinBroadcast.class, name="ReviveJoinBroadcast"), @Type(value = Qr.Login.class, name="Login")}) +public abstract class Qr { + +/** + * Ask the user whether to verify the contact. + *

+ * If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`]. + */ + public static class AskVerifyContact extends Qr { + /* Authentication code. */ + public String authcode; + /* ID of the contact. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user whether to join the group. */ + public static class AskVerifyGroup extends Qr { + /* Authentication code. */ + public String authcode; + /* ID of the contact. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Group ID. */ + public String grpid; + /* Group name. */ + public String grpname; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user whether to join the broadcast channel. */ + public static class AskJoinBroadcast extends Qr { + /* Authentication code. */ + public String authcode; + /* ID of the contact who owns the broadcast channel and created the QR code. */ + public Integer contact_id; + /* Fingerprint of the broadcast channel owner's key as scanned from the QR code. */ + public String fingerprint; + /* A string of random characters, uniquely identifying this broadcast channel across all databases/clients. Called `grpid` for historic reasons: The id of multi-user chats is always called `grpid` in the database because groups were once the only multi-user chats. */ + public String grpid; + /* Invite number. */ + public String invitenumber; + /* The user-visible name of this broadcast channel */ + public String name; + } + +/** + * Contact fingerprint is verified. + *

+ * Ask the user if they want to start chatting. + */ + public static class FprOk extends Qr { + /* Contact ID. */ + public Integer contact_id; + } + +/* Scanned fingerprint does not match the last seen fingerprint. */ + public static class FprMismatch extends Qr { + /* Contact ID. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer contact_id; + } + +/* The scanned QR code contains a fingerprint but no e-mail address. */ + public static class FprWithoutAddr extends Qr { + /* Key fingerprint. */ + public String fingerprint; + } + +/* Ask the user if they want to create an account on the given domain. */ + public static class Account extends Qr { + /* Server domain name. */ + public String domain; + } + +/* Provides a backup that can be retrieved using iroh-net based backup transfer protocol. */ + public static class Backup2 extends Qr { + /* Authentication token. */ + public String auth_token; + /* Iroh node address. */ + public String node_addr; + } + + public static class BackupTooNew extends Qr { + } + +/* Ask the user if they want to use the given service for video chats. */ + public static class WebrtcInstance extends Qr { + public String domain; + public String instance_pattern; + } + +/** + * Ask the user if they want to use the given proxy. + *

+ * Note that HTTP(S) URLs without a path and query parameters are treated as HTTP(S) proxy URL. UI may want to still offer to open the URL in the browser if QR code contents starts with `http://` or `https://` and the QR code was not scanned from the proxy configuration screen. + */ + public static class Proxy extends Qr { + /* Host extracted from the URL to display in the UI. */ + public String host; + /* Port extracted from the URL to display in the UI. */ + public Integer port; + /** + * Proxy URL. + *

+ * This is the URL that is going to be added. + */ + public String url; + } + +/** + * Contact address is scanned. + *

+ * Optionally, a draft message could be provided. Ask the user if they want to start chatting. + */ + public static class Addr extends Qr { + /* Contact ID. */ + public Integer contact_id; + /* Draft message. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String draft; + } + +/** + * URL scanned. + *

+ * Ask the user if they want to open a browser or copy the URL to clipboard. + */ + public static class Url extends Qr { + public String url; + } + +/** + * Text scanned. + *

+ * Ask the user if they want to copy the text to clipboard. + */ + public static class Text extends Qr { + public String text; + } + +/* Ask the user if they want to withdraw their own QR code. */ + public static class WithdrawVerifyContact extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user if they want to withdraw their own group invite QR code. */ + public static class WithdrawVerifyGroup extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Group ID. */ + public String grpid; + /* Group name. */ + public String grpname; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user if they want to withdraw their own broadcast channel invite QR code. */ + public static class WithdrawJoinBroadcast extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. Always `ContactId::SELF`. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* ID, uniquely identifying this chat. Called grpid for historic reasons. */ + public String grpid; + /* Invite number. */ + public String invitenumber; + /* Broadcast name. */ + public String name; + } + +/* Ask the user if they want to revive their own QR code. */ + public static class ReviveVerifyContact extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user if they want to revive their own group invite QR code. */ + public static class ReviveVerifyGroup extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Group ID. */ + public String grpid; + /* Contact ID. */ + public String grpname; + /* Invite number. */ + public String invitenumber; + } + +/* Ask the user if they want to revive their own broadcast channel invite QR code. */ + public static class ReviveJoinBroadcast extends Qr { + /* Authentication code. */ + public String authcode; + /* Contact ID. Always `ContactId::SELF`. */ + public Integer contact_id; + /* Fingerprint of the contact key as scanned from the QR code. */ + public String fingerprint; + /* Globally unique chat ID. Called grpid for historic reasons. */ + public String grpid; + /* Invite number. */ + public String invitenumber; + /* Broadcast name. */ + public String name; + } + +/** + * `dclogin:` scheme parameters. + *

+ * Ask the user if they want to login with the email address. + */ + public static class Login extends Qr { + public String address; + } + +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Reaction.java b/src/main/java/chat/delta/rpc/types/Reaction.java new file mode 100644 index 0000000000000000000000000000000000000000..3ac86d5b741cb9e1b1e11b3eda4863a75e6d9294 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Reaction.java @@ -0,0 +1,12 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +/* A single reaction emoji. */ +public class Reaction { + /* Emoji frequency. */ + public Integer count; + /* Emoji. */ + public String emoji; + /* True if we reacted with this emoji. */ + public Boolean isFromSelf; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Reactions.java b/src/main/java/chat/delta/rpc/types/Reactions.java new file mode 100644 index 0000000000000000000000000000000000000000..a7daf115563cd90d0b54f7b85fe443f2099044f1 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Reactions.java @@ -0,0 +1,10 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +/* Structure representing all reactions to a particular message. */ +public class Reactions { + /* Unique reactions and their count, sorted in descending order. */ + public java.util.List reactions; + /* Map from a contact to it's reaction to message. */ + public java.util.Map> reactionsByContact; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/SecurejoinSource.java b/src/main/java/chat/delta/rpc/types/SecurejoinSource.java new file mode 100644 index 0000000000000000000000000000000000000000..e9688b9e6389173397c3af6ea31489bed2a97c1b --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/SecurejoinSource.java @@ -0,0 +1,22 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum SecurejoinSource { + /* Because of some problem, it is unknown where the QR code came from. */ + Unknown, + + /* The user opened a link somewhere outside Delta Chat */ + ExternalLink, + + /* The user clicked on a link in a message inside Delta Chat */ + InternalLink, + + /* The user clicked "Paste from Clipboard" in the QR scan activity */ + Clipboard, + + /* The user clicked "Load QR code as image" in the QR scan activity */ + ImageLoaded, + + /* The user scanned a QR code */ + Scan, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/SecurejoinUiPath.java b/src/main/java/chat/delta/rpc/types/SecurejoinUiPath.java new file mode 100644 index 0000000000000000000000000000000000000000..9cbe52e907468b08409f589e82bd1cb4e00f549d --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/SecurejoinUiPath.java @@ -0,0 +1,13 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum SecurejoinUiPath { + /* The UI path is unknown, or the user didn't open the QR code screen at all. */ + Unknown, + + /* The user directly clicked on the QR icon in the main screen */ + QrIcon, + + /* The user first clicked on the `+` button in the main screen, and then on "New Contact" */ + NewContact, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Socket.java b/src/main/java/chat/delta/rpc/types/Socket.java new file mode 100644 index 0000000000000000000000000000000000000000..1dccf6935b2c461dfe3334f42b8b51fa9ac4033d --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Socket.java @@ -0,0 +1,16 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum Socket { + /* Unspecified socket security, select automatically. */ + automatic, + + /* TLS connection. */ + ssl, + + /* STARTTLS connection. */ + starttls, + + /* No TLS, plaintext connection. */ + plain, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/SystemMessageType.java b/src/main/java/chat/delta/rpc/types/SystemMessageType.java new file mode 100644 index 0000000000000000000000000000000000000000..f10b2c1a9af11086e91762859225667b254e414b --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/SystemMessageType.java @@ -0,0 +1,39 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum SystemMessageType { + Unknown, + GroupNameChanged, + GroupImageChanged, + MemberAddedToGroup, + MemberRemovedFromGroup, + AutocryptSetupMessage, + SecurejoinMessage, + LocationStreamingEnabled, + LocationOnly, + InvalidUnencryptedMail, + ChatE2ee, + ChatProtectionEnabled, + ChatProtectionDisabled, + WebxdcStatusUpdate, + CallAccepted, + CallEnded, + + /* 1:1 chats info message telling that SecureJoin has started and the user should wait for it to complete. */ + SecurejoinWait, + + /* 1:1 chats info message telling that SecureJoin is still running, but the user may already send messages. */ + SecurejoinWaitTimeout, + + /* Chat ephemeral message timer is changed. */ + EphemeralTimerChanged, + + /* Self-sent-message that contains only json used for multi-device-sync; if possible, we attach that to other messages as for locations. */ + MultiDeviceSync, + + /* Webxdc info added with `info` set in `send_webxdc_status_update()`. */ + WebxdcInfoMessage, + + /* This message contains a users iroh node address. */ + IrohNodeAddr, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/VcardContact.java b/src/main/java/chat/delta/rpc/types/VcardContact.java new file mode 100644 index 0000000000000000000000000000000000000000..f028f3357d775e2dd0fb24bbab040110d46cbd85 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/VcardContact.java @@ -0,0 +1,20 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class VcardContact { + /* Email address. */ + public String addr; + /* Contact color as hex string. */ + public String color; + /* The contact's name, or the email address if no name was given. */ + public String displayName; + /* Public PGP key in Base64. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String key; + /* Profile image in Base64. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String profileImage; + /* Last update timestamp. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public Integer timestamp; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/Viewtype.java b/src/main/java/chat/delta/rpc/types/Viewtype.java new file mode 100644 index 0000000000000000000000000000000000000000..f7cf9f9a00fe05d2bd6664bdf5a304825f24d449 --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/Viewtype.java @@ -0,0 +1,43 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public enum Viewtype { + Unknown, + + /* Text message. */ + Text, + + /* Image message. If the image is an animated GIF, the type `Viewtype.Gif` should be used. */ + Image, + + /* Animated GIF message. */ + Gif, + + /** + * Message containing a sticker, similar to image. NB: When sending, the message viewtype may be changed to `Image` by some heuristics like checking for transparent pixels. Use `Message::force_sticker()` to disable them. + *

+ * If possible, the ui should display the image without borders in a transparent way. A click on a sticker will offer to install the sticker set in some future. + */ + Sticker, + + /* Message containing an Audio file. */ + Audio, + + /* A voice message that was directly recorded by the user. For all other audio messages, the type `Viewtype.Audio` should be used. */ + Voice, + + /* Video messages. */ + Video, + + /* Message containing any file, eg. a PDF. */ + File, + + /* Message is a call. */ + Call, + + /* Message is an webxdc instance. */ + Webxdc, + + /* Message containing shared contacts represented as a vCard (virtual contact file) with email addresses and possibly other fields. Use `parse_vcard()` to retrieve them. */ + Vcard, +} \ No newline at end of file diff --git a/src/main/java/chat/delta/rpc/types/WebxdcMessageInfo.java b/src/main/java/chat/delta/rpc/types/WebxdcMessageInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..8b869d7edb27eaf85571c089e2b101bc0748380c --- /dev/null +++ b/src/main/java/chat/delta/rpc/types/WebxdcMessageInfo.java @@ -0,0 +1,36 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.rpc.types; + +public class WebxdcMessageInfo { + /* if the Webxdc represents a document, then this is the name of the document */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String document; + /** + * App icon file name. Defaults to an standard icon if nothing is set in the manifest. + *

+ * To get the file, use dc_msg_get_webxdc_blob(). (not yet in jsonrpc, use rust api or cffi for it) + *

+ * App icons should should be square, the implementations will add round corners etc. as needed. + */ + public String icon; + /* True if full internet access should be granted to the app. */ + public Boolean internetAccess; + /** + * The name of the app. + *

+ * Defaults to the filename if not set in the manifest. + */ + public String name; + /* Address to be used for `window.webxdc.selfAddr` in JS land. */ + public String selfAddr; + /* Milliseconds to wait before calling `sendUpdate()` again since the last call. Should be exposed to `window.sendUpdateInterval` in JS land. */ + public Integer sendUpdateInterval; + /* Maximum number of bytes accepted for a serialized update object. Should be exposed to `window.sendUpdateMaxSize` in JS land. */ + public Integer sendUpdateMaxSize; + /* URL where the source code of the Webxdc and other information can be found; defaults to an empty string. Implementations may offer an menu or a button to open this URL. */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String sourceCodeUrl; + /* short string describing the state of the app, sth. as "2 votes", "Highscore: 123", can be changed by the apps */ + @com.fasterxml.jackson.annotation.JsonSetter(nulls = com.fasterxml.jackson.annotation.Nulls.SET) + public String summary; +} \ No newline at end of file diff --git a/src/main/java/chat/delta/util/ListenableFuture.java b/src/main/java/chat/delta/util/ListenableFuture.java new file mode 100644 index 0000000000000000000000000000000000000000..d511ec7295d27ccc64a74d5f43f3f8014ce22712 --- /dev/null +++ b/src/main/java/chat/delta/util/ListenableFuture.java @@ -0,0 +1,14 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.util; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public interface ListenableFuture extends Future { + void addListener(Listener listener); + + public interface Listener { + public void onSuccess(T result); + public void onFailure(ExecutionException e); + } +} \ No newline at end of file diff --git a/src/main/java/chat/delta/util/SettableFuture.java b/src/main/java/chat/delta/util/SettableFuture.java new file mode 100644 index 0000000000000000000000000000000000000000..0a14c48763ecfc1bb232fe28f40f73cd56d82ad5 --- /dev/null +++ b/src/main/java/chat/delta/util/SettableFuture.java @@ -0,0 +1,137 @@ +/* Autogenerated file, do not edit manually */ +package chat.delta.util; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class SettableFuture implements ListenableFuture { + + private final List> listeners = new LinkedList<>(); + + private boolean completed; + private boolean canceled; + private volatile T result; + private volatile Throwable exception; + + public SettableFuture() { } + + public SettableFuture(T value) { + this.result = value; + this.completed = true; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (!completed && !canceled) { + canceled = true; + return true; + } + + return false; + } + + @Override + public synchronized boolean isCancelled() { + return canceled; + } + + @Override + public synchronized boolean isDone() { + return completed; + } + + public boolean set(T result) { + synchronized (this) { + if (completed || canceled) return false; + + this.result = result; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public boolean setException(Throwable throwable) { + synchronized (this) { + if (completed || canceled) return false; + + this.exception = throwable; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public void deferTo(ListenableFuture other) { + other.addListener(new Listener() { + @Override + public void onSuccess(T result) { + SettableFuture.this.set(result); + } + + @Override + public void onFailure(ExecutionException e) { + SettableFuture.this.setException(e.getCause()); + } + }); + } + + @Override + public synchronized T get() throws InterruptedException, ExecutionException { + while (!completed) wait(); + + if (exception != null) throw new ExecutionException(exception); + else return result; + } + + @Override + public synchronized T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException + { + long startTime = System.currentTimeMillis(); + + while (!completed && System.currentTimeMillis() - startTime > unit.toMillis(timeout)) { + wait(unit.toMillis(timeout)); + } + + if (!completed) throw new TimeoutException(); + else return get(); + } + + @Override + public void addListener(Listener listener) { + synchronized (this) { + listeners.add(listener); + + if (!completed) return; + } + + notifyListener(listener); + } + + private void notifyAllListeners() { + List> localListeners; + + synchronized (this) { + localListeners = new LinkedList<>(listeners); + } + + for (Listener listener : localListeners) { + notifyListener(listener); + } + } + + private void notifyListener(Listener listener) { + if (exception != null) listener.onFailure(new ExecutionException(exception)); + else listener.onSuccess(result); + } +} \ No newline at end of file diff --git a/src/main/java/com/b44t/messenger/DcAccounts.java b/src/main/java/com/b44t/messenger/DcAccounts.java new file mode 100644 index 0000000000000000000000000000000000000000..43e986e45b8ff2730c3b5d42a385eaa1f0596e28 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcAccounts.java @@ -0,0 +1,64 @@ +package com.b44t.messenger; + +public class DcAccounts { + + public DcAccounts(String dir) { + accountsCPtr = createAccountsCPtr(dir); + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + unref(); + } + + public void unref() { + if (accountsCPtr != 0) { + unrefAccountsCPtr(); + accountsCPtr = 0; + } + } + + public DcEventEmitter getEventEmitter () { return new DcEventEmitter(getEventEmitterCPtr()); } + public DcJsonrpcInstance getJsonrpcInstance () { return new DcJsonrpcInstance(getJsonrpcInstanceCPtr()); } + public void startIo () { + for (int accountId : getAll()) { + DcContext acc = getAccount(accountId); + if (acc.isEnabled()) { + acc.startIo(); + } + } + }; + public native void startIo2 (); + public native void stopIo (); + public native void maybeNetwork (); + public native void setPushDeviceToken (String token); + public native boolean backgroundFetch (int timeoutSeconds); + public native void stopBackgroundFetch (); + + public native int migrateAccount (String dbfile); + public native boolean removeAccount (int accountId); + public native int[] getAll (); + public DcContext getAccount (int accountId) { return new DcContext(getAccountCPtr(accountId)); } + public DcContext getSelectedAccount () { return new DcContext(getSelectedAccountCPtr()); } + public native boolean selectAccount (int accountId); + + // working with raw c-data + private long accountsCPtr; // CAVE: the name is referenced in the JNI + private native long createAccountsCPtr (String dir); + private native void unrefAccountsCPtr (); + private native long getEventEmitterCPtr (); + private native long getJsonrpcInstanceCPtr (); + private native long getAccountCPtr (int accountId); + private native long getSelectedAccountCPtr (); + + public boolean isAllChatmail() { + for (int accountId : getAll()) { + DcContext dcContext = getAccount(accountId); + if (!dcContext.isChatmail()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/b44t/messenger/DcBackupProvider.java b/src/main/java/com/b44t/messenger/DcBackupProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..459be4a364c9fa974530f73b95af7f43828572f5 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcBackupProvider.java @@ -0,0 +1,32 @@ +package com.b44t.messenger; + +public class DcBackupProvider { + + public DcBackupProvider(long backupProviderCPtr) { + this.backupProviderCPtr = backupProviderCPtr; + } + + public boolean isOk() { + return backupProviderCPtr != 0; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unref(); + } + + public void unref() { + if (backupProviderCPtr != 0) { + unrefBackupProviderCPtr(); + backupProviderCPtr = 0; + } + } + + public native String getQr (); + public native String getQrSvg (); + public native void waitForReceiver (); + + // working with raw c-data + private long backupProviderCPtr; // CAVE: the name is referenced in the JNI + private native void unrefBackupProviderCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcChat.java b/src/main/java/com/b44t/messenger/DcChat.java new file mode 100644 index 0000000000000000000000000000000000000000..90497a797303f26bfbd103f2345cfd8a91ed99d9 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcChat.java @@ -0,0 +1,76 @@ +package com.b44t.messenger; + +public class DcChat { + + public static final int DC_CHAT_TYPE_UNDEFINED = 0; + public static final int DC_CHAT_TYPE_SINGLE = 100; + public static final int DC_CHAT_TYPE_GROUP = 120; + public static final int DC_CHAT_TYPE_MAILINGLIST = 140; + public static final int DC_CHAT_TYPE_OUT_BROADCAST = 160; + public static final int DC_CHAT_TYPE_IN_BROADCAST = 165; + + public static final int DC_CHAT_NO_CHAT = 0; + public final static int DC_CHAT_ID_ARCHIVED_LINK = 6; + public final static int DC_CHAT_ID_ALLDONE_HINT = 7; + public final static int DC_CHAT_ID_LAST_SPECIAL = 9; + + public final static int DC_CHAT_VISIBILITY_NORMAL = 0; + public final static int DC_CHAT_VISIBILITY_ARCHIVED = 1; + public final static int DC_CHAT_VISIBILITY_PINNED = 2; + + private int accountId; + + public DcChat(int accountId, long chatCPtr) { + this.accountId = accountId; + this.chatCPtr = chatCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefChatCPtr(); + chatCPtr = 0; + } + + public int getAccountId () { return accountId; } + public native int getId (); + public native int getType (); + public native int getVisibility (); + public native String getName (); + public native String getMailinglistAddr(); + public native String getProfileImage (); + public native int getColor (); + public native boolean isEncrypted (); + public native boolean isUnpromoted (); + public native boolean isSelfTalk (); + public native boolean isDeviceTalk (); + public native boolean canSend (); + public native boolean isSendingLocations(); + public native boolean isMuted (); + public native boolean isContactRequest (); + + + // aliases and higher-level tools + + public boolean isMultiUser() { + int type = getType(); + return type != DC_CHAT_TYPE_SINGLE; + } + + public boolean isMailingList() { + return getType() == DC_CHAT_TYPE_MAILINGLIST; + } + + public boolean isInBroadcast() { + return getType() == DC_CHAT_TYPE_IN_BROADCAST; + } + public boolean isOutBroadcast() { + return getType() == DC_CHAT_TYPE_OUT_BROADCAST; + } + + // working with raw c-data + + private long chatCPtr; // CAVE: the name is referenced in the JNI + private native void unrefChatCPtr(); + public long getChatCPtr () { return chatCPtr; } + +} diff --git a/src/main/java/com/b44t/messenger/DcChatlist.java b/src/main/java/com/b44t/messenger/DcChatlist.java new file mode 100644 index 0000000000000000000000000000000000000000..4a250d15d94d76d52dbdc0538e4f9bbd889acfad --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcChatlist.java @@ -0,0 +1,46 @@ +package com.b44t.messenger; + +public class DcChatlist { + + private int accountId; + + public DcChatlist(int accountId, long chatlistCPtr) { + this.accountId = accountId; + this.chatlistCPtr = chatlistCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefChatlistCPtr(); + chatlistCPtr = 0; + } + + public int getAccountId() { return accountId; } + public native int getCnt (); + public native int getChatId (int index); + public DcChat getChat (int index) { return new DcChat(accountId, getChatCPtr(index)); } + public native int getMsgId (int index); + public DcMsg getMsg (int index) { return new DcMsg(getMsgCPtr(index)); } + public DcLot getSummary(int index, DcChat chat) { return new DcLot(getSummaryCPtr(index, chat==null? 0 : chat.getChatCPtr())); } + + public class Item { + public DcLot summary; + public int msgId; + public int chatId; + } + + public Item getItem(int index) { + Item item = new Item(); + item.summary = getSummary(index, null); + item.msgId = getMsgId(index); + item.chatId = getChatId(index); + return item; + } + + // working with raw c-data + private long chatlistCPtr; // CAVE: the name is referenced in the JNI + private native void unrefChatlistCPtr(); + private native long getChatCPtr (int index); + private native long getMsgCPtr (int index); + private native long getSummaryCPtr (int index, long chatCPtr); +} diff --git a/src/main/java/com/b44t/messenger/DcContact.java b/src/main/java/com/b44t/messenger/DcContact.java new file mode 100644 index 0000000000000000000000000000000000000000..c1db787df3e68ad6ef6aa0393151056fc285d838 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcContact.java @@ -0,0 +1,68 @@ +package com.b44t.messenger; + +public class DcContact { + + public final static int DC_CONTACT_ID_SELF = 1; + public final static int DC_CONTACT_ID_INFO = 2; + public final static int DC_CONTACT_ID_DEVICE = 5; + public final static int DC_CONTACT_ID_LAST_SPECIAL = 9; + public final static int DC_CONTACT_ID_NEW_CLASSIC_CONTACT= -1; // used by the UI, not valid to the core + public final static int DC_CONTACT_ID_NEW_GROUP = -2; // - " - + public final static int DC_CONTACT_ID_ADD_MEMBER = -3; // - " - + public final static int DC_CONTACT_ID_QR_INVITE = -4; // - " - + public final static int DC_CONTACT_ID_NEW_BROADCAST = -5; // - " - + public final static int DC_CONTACT_ID_ADD_ACCOUNT = -6; // - " - + public final static int DC_CONTACT_ID_NEW_UNENCRYPTED_GROUP = -7; // - " - + + public DcContact(long contactCPtr) { + this.contactCPtr = contactCPtr; + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + unrefContactCPtr(); + contactCPtr = 0; + } + + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof DcContact)) { + return false; + } + + DcContact that = (DcContact) other; + return this.getId()==that.getId(); + } + + @Override + public int hashCode() { + return this.getId(); + } + + @Override + public String toString() { + return getAddr(); + } + + public native int getId (); + public native String getName (); + public native String getAuthName (); + public native String getDisplayName (); + public native String getAddr (); + public native String getProfileImage(); + public native int getColor (); + public native String getStatus (); + public native long getLastSeen (); + public native boolean wasSeenRecently(); + public native boolean isBlocked (); + public native boolean isVerified (); + public native boolean isKeyContact (); + public native int getVerifierId (); + public native boolean isBot (); + + // working with raw c-data + private long contactCPtr; // CAVE: the name is referenced in the JNI + private native void unrefContactCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcContext.java b/src/main/java/com/b44t/messenger/DcContext.java new file mode 100644 index 0000000000000000000000000000000000000000..4032bff7315071f1df9bf62ceaf00eea90ec67a0 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcContext.java @@ -0,0 +1,288 @@ +package com.b44t.messenger; + +public class DcContext { + + public final static int DC_EVENT_INFO = 100; + public final static int DC_EVENT_WARNING = 300; + public final static int DC_EVENT_ERROR = 400; + public final static int DC_EVENT_ERROR_SELF_NOT_IN_GROUP = 410; + public final static int DC_EVENT_MSGS_CHANGED = 2000; + public final static int DC_EVENT_REACTIONS_CHANGED = 2001; + public final static int DC_EVENT_INCOMING_REACTION = 2002; + public final static int DC_EVENT_INCOMING_WEBXDC_NOTIFY = 2003; + public final static int DC_EVENT_INCOMING_MSG = 2005; + public final static int DC_EVENT_MSGS_NOTICED = 2008; + public final static int DC_EVENT_MSG_DELIVERED = 2010; + public final static int DC_EVENT_MSG_FAILED = 2012; + public final static int DC_EVENT_MSG_READ = 2015; + public final static int DC_EVENT_CHAT_MODIFIED = 2020; + public final static int DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED = 2021; + public final static int DC_EVENT_CHAT_DELETED = 2023; + public final static int DC_EVENT_CONTACTS_CHANGED = 2030; + public final static int DC_EVENT_LOCATION_CHANGED = 2035; + public final static int DC_EVENT_CONFIGURE_PROGRESS = 2041; + public final static int DC_EVENT_IMEX_PROGRESS = 2051; + public final static int DC_EVENT_IMEX_FILE_WRITTEN = 2052; + public final static int DC_EVENT_SECUREJOIN_INVITER_PROGRESS = 2060; + public final static int DC_EVENT_SECUREJOIN_JOINER_PROGRESS = 2061; + public final static int DC_EVENT_CONNECTIVITY_CHANGED = 2100; + public final static int DC_EVENT_SELFAVATAR_CHANGED = 2110; + public final static int DC_EVENT_WEBXDC_STATUS_UPDATE = 2120; + public final static int DC_EVENT_WEBXDC_INSTANCE_DELETED = 2121; + public final static int DC_EVENT_WEBXDC_REALTIME_DATA = 2150; + public final static int DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE = 2200; + public final static int DC_EVENT_INCOMING_CALL = 2550; + public final static int DC_EVENT_INCOMING_CALL_ACCEPTED = 2560; + public final static int DC_EVENT_OUTGOING_CALL_ACCEPTED = 2570; + public final static int DC_EVENT_CALL_ENDED = 2580; + + public final static int DC_IMEX_EXPORT_SELF_KEYS = 1; + public final static int DC_IMEX_IMPORT_SELF_KEYS = 2; + public final static int DC_IMEX_EXPORT_BACKUP = 11; + public final static int DC_IMEX_IMPORT_BACKUP = 12; + + public final static int DC_GCL_VERIFIED_ONLY = 1; + public final static int DC_GCL_ADD_SELF = 2; + public final static int DC_GCL_ADDRESS = 0x04; + public final static int DC_GCL_ARCHIVED_ONLY = 0x01; + public final static int DC_GCL_NO_SPECIALS = 0x02; + public final static int DC_GCL_ADD_ALLDONE_HINT = 0x04; + public final static int DC_GCL_FOR_FORWARDING = 0x08; + + public final static int DC_GCM_ADDDAYMARKER = 0x01; + + public final static int DC_QR_ASK_VERIFYCONTACT = 200; + public final static int DC_QR_ASK_VERIFYGROUP = 202; + public final static int DC_QR_ASK_JOIN_BROADCAST= 204; + public final static int DC_QR_FPR_OK = 210; + public final static int DC_QR_FPR_MISMATCH = 220; + public final static int DC_QR_FPR_WITHOUT_ADDR = 230; + public final static int DC_QR_ACCOUNT = 250; + public final static int DC_QR_BACKUP2 = 252; + public final static int DC_QR_BACKUP_TOO_NEW = 255; + public final static int DC_QR_WEBRTC = 260; + public final static int DC_QR_PROXY = 271; + public final static int DC_QR_ADDR = 320; + public final static int DC_QR_TEXT = 330; + public final static int DC_QR_URL = 332; + public final static int DC_QR_ERROR = 400; + public final static int DC_QR_WITHDRAW_VERIFYCONTACT = 500; + public final static int DC_QR_WITHDRAW_VERIFYGROUP = 502; + public final static int DC_QR_WITHDRAW_JOINBROADCAST = 504; + public final static int DC_QR_REVIVE_VERIFYCONTACT = 510; + public final static int DC_QR_REVIVE_VERIFYGROUP = 512; + public final static int DC_QR_REVIVE_JOINBROADCAST = 514; + public final static int DC_QR_LOGIN = 520; + + public final static int DC_SOCKET_AUTO = 0; + public final static int DC_SOCKET_SSL = 1; + public final static int DC_SOCKET_STARTTLS = 2; + public final static int DC_SOCKET_PLAIN = 3; + + public final static int DC_SHOW_EMAILS_OFF = 0; + public final static int DC_SHOW_EMAILS_ACCEPTED_CONTACTS = 1; + public final static int DC_SHOW_EMAILS_ALL = 2; + + public final static int DC_MEDIA_QUALITY_BALANCED = 0; + public final static int DC_MEDIA_QUALITY_WORSE = 1; + + public final static int DC_CONNECTIVITY_NOT_CONNECTED = 1000; + public final static int DC_CONNECTIVITY_CONNECTING = 2000; + public final static int DC_CONNECTIVITY_WORKING = 3000; + public final static int DC_CONNECTIVITY_CONNECTED = 4000; + + private static final String CONFIG_ACCOUNT_ENABLED = "ui.enabled"; + private static final String CONFIG_MUTE_MENTIONS_IF_MUTED = "ui.mute_mentions_if_muted"; + + // when using DcAccounts, use Rpc.addAccount() instead + public DcContext(String osName, String dbfile) { + contextCPtr = createContextCPtr(osName, dbfile); + } + + public DcContext(long contextCPtr) { + this.contextCPtr = contextCPtr; + } + + public boolean isOk() { + return contextCPtr != 0; + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + if (contextCPtr != 0) { + unrefContextCPtr(); + contextCPtr = 0; + } + } + + public native int getAccountId (); + + // when using DcAccounts, use DcAccounts.getEventEmitter() instead + public DcEventEmitter getEventEmitter () { return new DcEventEmitter(getEventEmitterCPtr()); } + + public native void setStockTranslation (int stockId, String translation); + public native String getBlobdir (); + public native String getLastError (); + public native void stopOngoingProcess (); + public native int isConfigured (); + public native boolean open (String passphrase); + public native boolean isOpen (); + + // when using DcAccounts, use DcAccounts.startIo() instead + public native void startIo (); + + // when using DcAccounts, use DcAccounts.stopIo() instead + public native void stopIo (); + + // when using DcAccounts, use DcAccounts.maybeNetwork() instead + public native void maybeNetwork (); + + public native void setConfig (String key, String value); + public void setConfigInt (String key, int value) { setConfig(key, Integer.toString(value)); } + public native boolean setConfigFromQr (String qr); + public native String getConfig (String key); + public int getConfigInt (String key) { return getConfigInt(key, 0); } + public int getConfigInt (String key, int defValue) { try{return Integer.parseInt(getConfig(key));} catch(Exception e) {} return defValue; } + public native String getInfo (); + public native int getConnectivity (); + public native String getConnectivityHtml (); + public native String initiateKeyTransfer (); + public native void imex (int what, String dir); + public native String imexHasBackup (String dir); + public DcBackupProvider newBackupProvider () { return new DcBackupProvider(newBackupProviderCPtr()); } + public native boolean receiveBackup (String qr); + public native boolean mayBeValidAddr (String addr); + public native int lookupContactIdByAddr(String addr); + public native int[] getContacts (int flags, String query); + public native int[] getBlockedContacts (); + public DcContact getContact (int contact_id) { return new DcContact(getContactCPtr(contact_id)); } + public native int createContact (String name, String addr); + public native void blockContact (int id, int block); + public native String getContactEncrInfo (int contact_id); + public native boolean deleteContact (int id); + public native int addAddressBook (String adrbook); + public DcChatlist getChatlist (int listflags, String query, int queryId) { return new DcChatlist(getAccountId(), getChatlistCPtr(listflags, query, queryId)); } + public DcChat getChat (int chat_id) { return new DcChat(getAccountId(), getChatCPtr(chat_id)); } + public native String getChatEncrInfo (int chat_id); + public native void markseenMsgs (int msg_ids[]); + public native void marknoticedChat (int chat_id); + public native void setChatVisibility (int chat_id, int visibility); + public native int getChatIdByContactId (int contact_id); + public native int createChatByContactId(int contact_id); + public native int createGroupChat (String name); + public native int createBroadcastList (); + public native boolean isContactInChat (int chat_id, int contact_id); + public native int addContactToChat (int chat_id, int contact_id); + public native int removeContactFromChat(int chat_id, int contact_id); + public native void setDraft (int chat_id, DcMsg msg/*null=delete*/); + public DcMsg getDraft (int chat_id) { return new DcMsg(getDraftCPtr(chat_id)); } + public native int setChatName (int chat_id, String name); + public native int setChatProfileImage (int chat_id, String name); + public native int[] getChatMsgs (int chat_id, int flags, int marker1before); + public native int[] searchMsgs (int chat_id, String query); + public native int[] getFreshMsgs (); + public native int[] getChatMedia (int chat_id, int type1, int type2, int type3); + public native int[] getChatContacts (int chat_id); + public native int getChatEphemeralTimer (int chat_id); + public native boolean setChatEphemeralTimer (int chat_id, int timer); + public native boolean setChatMuteDuration (int chat_id, long duration); + public native void deleteChat (int chat_id); + public native void blockChat (int chat_id); + public native void acceptChat (int chat_id); + public DcMsg getMsg (int msg_id) { return new DcMsg(getMsgCPtr(msg_id)); } + public native void sendEditRequest (int msg_id, String text); + public native String getMsgInfo (int id); + public native String getMsgHtml (int msg_id); + public native void downloadFullMsg (int msg_id); + public native int getFreshMsgCount (int chat_id); + public native int estimateDeletionCount(boolean from_server, long seconds); + public native void deleteMsgs (int msg_ids[]); + public native void sendDeleteRequest (int msg_ids[]); + public native void forwardMsgs (int msg_ids[], int chat_id); + public native void saveMsgs (int msg_ids[]); + public native boolean resendMsgs (int msg_ids[]); + public native int sendMsg (int chat_id, DcMsg msg); + public native int sendTextMsg (int chat_id, String text); + public native boolean sendWebxdcStatusUpdate(int msg_id, String payload); + public native String getWebxdcStatusUpdates(int msg_id, int last_known_serial); + public native void setWebxdcIntegration (String file); + public native int initWebxdcIntegration(int chat_id); + public native int addDeviceMsg (String label, DcMsg msg); + public native boolean wasDeviceMsgEverAdded(String label); + public DcLot checkQr (String qr) { return new DcLot(checkQrCPtr(qr)); } + public native String getSecurejoinQr (int chat_id); + public native String getSecurejoinQrSvg (int chat_id); + public native String createQrSvg (String payload); + public native int joinSecurejoin (String qr); + public native void sendLocationsToChat (int chat_id, int seconds); + public native boolean isSendingLocationsToChat(int chat_id); + public DcProvider getProviderFromEmailWithDns (String email) { long cptr = getProviderFromEmailWithDnsCPtr(email); return cptr!=0 ? new DcProvider(cptr) : null; } + + public boolean isEnabled() { + return !"0".equals(getConfig(CONFIG_ACCOUNT_ENABLED)); + } + + public void setEnabled(boolean enabled) { + setConfigInt(CONFIG_ACCOUNT_ENABLED, enabled? 1 : 0); + if (enabled) { + startIo(); + } else { + stopIo(); + } + } + + public boolean isMentionsEnabled() { + return getConfigInt(CONFIG_MUTE_MENTIONS_IF_MUTED) != 1; + } + + public void setMentionsEnabled(boolean enabled) { + setConfigInt(CONFIG_MUTE_MENTIONS_IF_MUTED, enabled? 0 : 1); + } + + public String getName() { + String displayname = getConfig("displayname"); + if (displayname.isEmpty()) { + displayname = getContact(DcContact.DC_CONTACT_ID_SELF).getAddr(); + } + return displayname; + } + + public boolean isChatmail() { + return getConfigInt("is_chatmail") == 1; + } + + public boolean isMuted() { + return getConfigInt("is_muted") == 1; + } + + public void setMuted(boolean muted) { + setConfigInt("is_muted", muted? 1 : 0); + } + + public void restartIo() { + if (!isEnabled()) return; + stopIo(); + startIo(); + } + + /** + * @return true if at least one chat has location streaming enabled + */ + public native boolean setLocation (float latitude, float longitude, float accuracy); + + // working with raw c-data + private long contextCPtr; // CAVE: the name is referenced in the JNI + private native long createContextCPtr(String osName, String dbfile); + private native void unrefContextCPtr (); + private native long getEventEmitterCPtr(); + public native long createMsgCPtr (int viewtype); + private native long getChatlistCPtr (int listflags, String query, int queryId); + private native long getChatCPtr (int chat_id); + private native long getMsgCPtr (int id); + private native long getDraftCPtr (int id); + private native long getContactCPtr (int id); + private native long checkQrCPtr (String qr); + private native long getProviderFromEmailWithDnsCPtr (String addr); + private native long newBackupProviderCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcEvent.java b/src/main/java/com/b44t/messenger/DcEvent.java new file mode 100644 index 0000000000000000000000000000000000000000..1acfc2ee0d2f7e95dbf073b9af0aec0c52954ad9 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcEvent.java @@ -0,0 +1,25 @@ +package com.b44t.messenger; + +public class DcEvent { + + public DcEvent(long eventCPtr) { + this.eventCPtr = eventCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefEventCPtr(); + eventCPtr = 0; + } + + public native int getId (); + public native int getData1Int (); + public native int getData2Int (); + public native String getData2Str (); + public native byte[] getData2Blob(); + public native int getAccountId(); + + // working with raw c-data + private long eventCPtr; // CAVE: the name is referenced in the JNI + private native void unrefEventCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcEventEmitter.java b/src/main/java/com/b44t/messenger/DcEventEmitter.java new file mode 100644 index 0000000000000000000000000000000000000000..bd7c8ad548bc3382f04e012f16153f162d82b1d2 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcEventEmitter.java @@ -0,0 +1,24 @@ +package com.b44t.messenger; + +public class DcEventEmitter { + + public DcEventEmitter(long eventEmitterCPtr) { + this.eventEmitterCPtr = eventEmitterCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefEventEmitterCPtr(); + eventEmitterCPtr = 0; + } + + public DcEvent getNextEvent () { + long eventCPtr = getNextEventCPtr(); + return eventCPtr == 0 ? null : new DcEvent(eventCPtr); + } + + // working with raw c-data + private long eventEmitterCPtr; // CAVE: the name is referenced in the JNI + private native long getNextEventCPtr (); + private native void unrefEventEmitterCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcJsonrpcInstance.java b/src/main/java/com/b44t/messenger/DcJsonrpcInstance.java new file mode 100644 index 0000000000000000000000000000000000000000..57b353b44040d1e279bd06e6d0714f84c4f6e24a --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcJsonrpcInstance.java @@ -0,0 +1,21 @@ +package com.b44t.messenger; + +public class DcJsonrpcInstance { + + public DcJsonrpcInstance(long jsonrpcInstanceCPtr) { + this.jsonrpcInstanceCPtr = jsonrpcInstanceCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefJsonrpcInstanceCPtr(); + jsonrpcInstanceCPtr = 0; + } + + public native void request(String request); + public native String getNextResponse(); + + // working with raw c-data + private long jsonrpcInstanceCPtr; // CAVE: the name is referenced in the JNI + private native void unrefJsonrpcInstanceCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcLot.java b/src/main/java/com/b44t/messenger/DcLot.java new file mode 100644 index 0000000000000000000000000000000000000000..97ab2f013fde380249c15082cee79bb8ba9b8d3c --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcLot.java @@ -0,0 +1,29 @@ +package com.b44t.messenger; + +public class DcLot { + + public final static int DC_TEXT1_DRAFT = 1; + public final static int DC_TEXT1_USERNAME = 2; + public final static int DC_TEXT1_SELF = 3; + + public DcLot(long lotCPtr) { + this.lotCPtr = lotCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefLotCPtr(); + lotCPtr = 0; + } + + public native String getText1 (); + public native int getText1Meaning(); + public native String getText2 (); + public native long getTimestamp (); + public native int getState (); + public native int getId (); + + // working with raw c-data + private long lotCPtr; // CAVE: the name is referenced in the JNI + private native void unrefLotCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/DcMediaGalleryElement.java b/src/main/java/com/b44t/messenger/DcMediaGalleryElement.java new file mode 100644 index 0000000000000000000000000000000000000000..288ae7f820f6b48206640e549d023dbcd1bf892f --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcMediaGalleryElement.java @@ -0,0 +1,44 @@ +package com.b44t.messenger; + +/** + * Contains a list of media entries, their respective positions and ability to move through it. + */ +public class DcMediaGalleryElement { + + final int[] mediaMsgs; + int position; + final DcContext context; + public DcMediaGalleryElement(int[] mediaMsgs, int position, DcContext context, boolean leftIsRecent) { + this.mediaMsgs = mediaMsgs; + this.position = position; + this.context = context; + + // normal state is left is recent. If the ui needs right to be recent we reverse the order here. + if (!leftIsRecent) { + for (int ii = 0; ii < mediaMsgs.length / 2; ii++) { + int tmp = mediaMsgs[ii]; + int opposite = mediaMsgs.length - 1 - ii; + mediaMsgs[ii] = mediaMsgs[opposite]; + mediaMsgs[opposite] = tmp; + } + } + } + + public int getCount() { + return mediaMsgs.length; + } + + public int getPosition() { + return position; + } + + public void moveToPosition(int newPosition) { + if(newPosition < 0 || newPosition >= mediaMsgs.length) + throw new IllegalArgumentException("can't move outside of known area."); + position = newPosition; + } + + public DcMsg getMessage() { + return context.getMsg(mediaMsgs[position]); + } +} diff --git a/src/main/java/com/b44t/messenger/DcMsg.java b/src/main/java/com/b44t/messenger/DcMsg.java new file mode 100644 index 0000000000000000000000000000000000000000..ac9282687c1ef1feb926d6ae50dbfba0ed2fe690 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcMsg.java @@ -0,0 +1,266 @@ +package com.b44t.messenger; + +import android.text.TextUtils; + +import org.json.JSONObject; + +import java.io.File; +import java.util.Set; + +public class DcMsg { + + public final static int DC_MSG_UNDEFINED = 0; + public final static int DC_MSG_TEXT = 10; + public final static int DC_MSG_IMAGE = 20; + public final static int DC_MSG_GIF = 21; + public final static int DC_MSG_STICKER = 23; + public final static int DC_MSG_AUDIO = 40; + public final static int DC_MSG_VOICE = 41; + public final static int DC_MSG_VIDEO = 50; + public final static int DC_MSG_FILE = 60; + public final static int DC_MSG_CALL = 71; + public final static int DC_MSG_WEBXDC = 80; + public final static int DC_MSG_VCARD = 90; + + public final static int DC_INFO_UNKNOWN = 0; + public final static int DC_INFO_GROUP_NAME_CHANGED = 2; + public final static int DC_INFO_GROUP_IMAGE_CHANGED = 3; + public final static int DC_INFO_MEMBER_ADDED_TO_GROUP = 4; + public final static int DC_INFO_MEMBER_REMOVED_FROM_GROUP = 5; + public final static int DC_INFO_AUTOCRYPT_SETUP_MESSAGE = 6; + public final static int DC_INFO_SECURE_JOIN_MESSAGE = 7; + public final static int DC_INFO_LOCATIONSTREAMING_ENABLED = 8; + public final static int DC_INFO_LOCATION_ONLY = 9; + public final static int DC_INFO_EPHEMERAL_TIMER_CHANGED = 10; + public final static int DC_INFO_PROTECTION_ENABLED = 11; + public final static int DC_INFO_INVALID_UNENCRYPTED_MAIL = 13; + public final static int DC_INFO_WEBXDC_INFO_MESSAGE = 32; + public final static int DC_INFO_CHAT_E2EE = 50; + + public final static int DC_STATE_UNDEFINED = 0; + public final static int DC_STATE_IN_FRESH = 10; + public final static int DC_STATE_IN_NOTICED = 13; + public final static int DC_STATE_IN_SEEN = 16; + public final static int DC_STATE_OUT_PREPARING = 18; + public final static int DC_STATE_OUT_DRAFT = 19; + public final static int DC_STATE_OUT_PENDING = 20; + public final static int DC_STATE_OUT_FAILED = 24; + public final static int DC_STATE_OUT_DELIVERED = 26; + public final static int DC_STATE_OUT_MDN_RCVD = 28; + + public final static int DC_DOWNLOAD_DONE = 0; + public final static int DC_DOWNLOAD_AVAILABLE = 10; + public final static int DC_DOWNLOAD_FAILURE = 20; + public final static int DC_DOWNLOAD_UNDECIPHERABLE = 30; + public final static int DC_DOWNLOAD_IN_PROGRESS = 1000; + + public static final int DC_MSG_NO_ID = 0; + public final static int DC_MSG_ID_MARKER1 = 1; + public final static int DC_MSG_ID_DAYMARKER = 9; + + public final static int DC_VIDEOCHATTYPE_UNKNOWN = 0; + public final static int DC_VIDEOCHATTYPE_BASICWEBRTC = 1; + + private static final String TAG = DcMsg.class.getSimpleName(); + + public DcMsg(DcContext context, int viewtype) { + msgCPtr = context.createMsgCPtr(viewtype); + } + + public DcMsg(long msgCPtr) { + this.msgCPtr = msgCPtr; + } + + public boolean isOk() { + return msgCPtr != 0; + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + unrefMsgCPtr(); + msgCPtr = 0; + } + + @Override + public int hashCode() { + return this.getId(); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof DcMsg)) { + return false; + } + + DcMsg that = (DcMsg) other; + return this.getId()==that.getId() && this.getId()!=0; + } + + /** + * If given a message, calculates the position of the message in the chat + */ + public static int getMessagePosition(DcMsg msg, DcContext dcContext) { + int msgs[] = dcContext.getChatMsgs(msg.getChatId(), 0, 0); + int startingPosition = -1; + int msgId = msg.getId(); + for (int i = 0; i < msgs.length; i++) { + if (msgs[i] == msgId) { + startingPosition = msgs.length - 1 - i; + break; + } + } + return startingPosition; + } + + public native int getId (); + public native String getText (); + public native String getSubject (); + public native long getTimestamp (); + public native long getSortTimestamp (); + public native boolean hasDeviatingTimestamp(); + public native boolean hasLocation (); + private native int getViewType (); + public int getType () { return getDownloadState()==DC_DOWNLOAD_DONE? getViewType() : DC_MSG_TEXT; } + public native int getInfoType (); + public native int getInfoContactId (); + public native int getState (); + public native int getDownloadState (); + public native int getChatId (); + public native int getFromId (); + public native int getWidth (int def); + public native int getHeight (int def); + public native int getDuration (); + public native void lateFilingMediaSize(int width, int height, int duration); + public DcLot getSummary (DcChat chat) { return new DcLot(getSummaryCPtr(chat.getChatCPtr())); } + public native String getSummarytext (int approx_characters); + public native int showPadlock (); + public boolean hasFile () { String file = getFile(); return file!=null && !file.isEmpty(); } + public native String getFile (); + public native String getFilemime (); + public native String getFilename (); + public native long getFilebytes (); + public native byte[] getWebxdcBlob (String filename); + public JSONObject getWebxdcInfo () { + try { + String json = getWebxdcInfoJson(); + if (json != null && !json.isEmpty()) return new JSONObject(json); + } catch(Exception e) { + e.printStackTrace(); + } + return new JSONObject(); + } + public native String getWebxdcHref (); + public native boolean isForwarded (); + public native boolean isInfo (); + public native boolean hasHtml (); + public native String getSetupCodeBegin (); + public native void setText (String text); + public native void setSubject (String text); + public native void setHtml (String text); + public native void forceSticker (); + public native void setFileAndDeduplicate(String file, String name, String filemime); + public native void setDimension (int width, int height); + public native void setDuration (int duration); + public native void setLocation (float latitude, float longitude); + public native String getPOILocation (); + public void setQuote (DcMsg quote) { setQuoteCPtr(quote.msgCPtr); } + public native String getQuotedText (); + public native String getError (); + public native String getOverrideSenderName(); + public native boolean isEdited (); + + public String getSenderName(DcContact dcContact) { + String overrideName = getOverrideSenderName(); + if (overrideName != null) { + return "~" + overrideName; + } else { + return dcContact.getDisplayName(); + } + } + + public DcMsg getQuotedMsg () { + long cPtr = getQuotedMsgCPtr(); + return cPtr != 0 ? new DcMsg(cPtr) : null; + } + + public DcMsg getParent() { + long cPtr = getParentCPtr(); + return cPtr != 0 ? new DcMsg(cPtr) : null; + } + + public native int getOriginalMsgId (); + public native int getSavedMsgId (); + + public boolean canSave() { + // saving info-messages out of context results in confusion, see https://github.com/deltachat/deltachat-ios/issues/2567 + return !isInfo(); + } + + public File getFileAsFile() { + if(getFile()==null) + throw new AssertionError("expected a file to be present."); + return new File(getFile()); + } + + // aliases and higher-level tools + public static int[] msgSetToIds(final Set dcMsgs) { + if (dcMsgs == null) { + return new int[0]; + } + int[] ids = new int[dcMsgs.size()]; + int i = 0; + for (DcMsg dcMsg : dcMsgs) { + ids[i++] = dcMsg.getId(); + } + return ids; + } + + public boolean isOutgoing() { + return getFromId() == DcContact.DC_CONTACT_ID_SELF; + } + + public String getDisplayBody() { + return getText(); + } + + public String getBody() { + return getText(); + } + + public long getDateReceived() { + return getTimestamp(); + } + + public boolean isFailed() { + return (getState() == DC_STATE_OUT_FAILED) || (!TextUtils.isEmpty(getError())); + } + public boolean isPreparing() { + return getState() == DC_STATE_OUT_PREPARING; + } + public boolean isSecure() { + return showPadlock()!=0; + } + public boolean isPending() { + return getState() == DC_STATE_OUT_PENDING; + } + public boolean isDelivered() { + return getState() == DC_STATE_OUT_DELIVERED; + } + public boolean isRemoteRead() { + return getState() == DC_STATE_OUT_MDN_RCVD; + } + public boolean isSeen() { + return getState() == DC_STATE_IN_SEEN; + } + + + // working with raw c-data + private long msgCPtr; // CAVE: the name is referenced in the JNI + private native void unrefMsgCPtr (); + private native long getSummaryCPtr (long chatCPtr); + private native void setQuoteCPtr (long quoteCPtr); + private native long getQuotedMsgCPtr (); + private native long getParentCPtr (); + private native String getWebxdcInfoJson (); +}; diff --git a/src/main/java/com/b44t/messenger/DcProvider.java b/src/main/java/com/b44t/messenger/DcProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..09941cfe4bc53b8f47d506baaf5bf868c8273575 --- /dev/null +++ b/src/main/java/com/b44t/messenger/DcProvider.java @@ -0,0 +1,26 @@ +package com.b44t.messenger; + +public class DcProvider { + + public final static int DC_PROVIDER_STATUS_OK = 1; + public final static int DC_PROVIDER_STATUS_PREPARATION = 2; + public final static int DC_PROVIDER_STATUS_BROKEN = 3; + + public DcProvider(long providerCPtr) { + this.providerCPtr = providerCPtr; + } + + @Override protected void finalize() throws Throwable { + super.finalize(); + unrefProviderCPtr(); + providerCPtr = 0; + } + + public native int getStatus (); + public native String getBeforeLoginHint (); + public native String getOverviewPage (); + + // working with raw c-data + private long providerCPtr; // CAVE: the name is referenced in the JNI + private native void unrefProviderCPtr(); +} diff --git a/src/main/java/com/b44t/messenger/FFITransport.java b/src/main/java/com/b44t/messenger/FFITransport.java new file mode 100644 index 0000000000000000000000000000000000000000..401048c5230ff02aaf051d96470eca98ed63e96e --- /dev/null +++ b/src/main/java/com/b44t/messenger/FFITransport.java @@ -0,0 +1,22 @@ +package com.b44t.messenger; + +import chat.delta.rpc.BaseTransport; + +/* RPC transport over C FFI */ +public class FFITransport extends BaseTransport { + private final DcJsonrpcInstance dcJsonrpcInstance; + + public FFITransport(DcJsonrpcInstance dcJsonrpcInstance) { + this.dcJsonrpcInstance = dcJsonrpcInstance; + } + + @Override + protected void sendRequest(String jsonRequest) { + dcJsonrpcInstance.request(jsonRequest); + } + + @Override + protected String getResponse() { + return dcJsonrpcInstance.getNextResponse(); + } +} \ No newline at end of file diff --git a/src/main/java/com/codewaves/LICENSE.txt b/src/main/java/com/codewaves/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba9eb78e6507e19ab18ef7862ad5e188b1cb1a05 --- /dev/null +++ b/src/main/java/com/codewaves/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Sergej Kravcenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java b/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..fb409a73d078b493e60ad6b066a1ba2a308e1fd7 --- /dev/null +++ b/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java @@ -0,0 +1,612 @@ +package com.codewaves.stickyheadergrid; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.security.InvalidParameterException; +import java.util.ArrayList; + +/** + * Created by Sergej Kravcenko on 4/24/2017. + * Copyright (c) 2017 Sergej Kravcenko + */ + +@SuppressWarnings({"unused", "WeakerAccess"}) +public abstract class StickyHeaderGridAdapter extends RecyclerView.Adapter { + public static final String TAG = "StickyHeaderGridAdapter"; + + public static final int TYPE_HEADER = 0; + public static final int TYPE_ITEM = 1; + + private ArrayList

mSections; + private int[] mSectionIndices; + private int mTotalItemNumber; + + @SuppressWarnings("WeakerAccess") + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + public boolean isHeader() { + return false; + } + + + public int getSectionItemViewType() { + return StickyHeaderGridAdapter.externalViewType(getItemViewType()); + } + } + + public static class ItemViewHolder extends ViewHolder { + public ItemViewHolder(View itemView) { + super(itemView); + } + } + + public static class HeaderViewHolder extends ViewHolder { + public HeaderViewHolder(View itemView) { + super(itemView); + } + + @Override + public boolean isHeader() { + return true; + } + } + + private static class Section { + private int position; + private int itemNumber; + private int length; + } + + private void calculateSections() { + mSections = new ArrayList<>(); + + int total = 0; + int sectionCount = getSectionCount(); + for (int s = 0; s < sectionCount; s++) { + final Section section = new Section(); + section.position = total; + section.itemNumber = getSectionItemCount(s); + section.length = section.itemNumber + 1; + mSections.add(section); + + total += section.length; + } + mTotalItemNumber = total; + + total = 0; + mSectionIndices = new int[mTotalItemNumber]; + for (int s = 0; s < sectionCount; s++) { + final Section section = mSections.get(s); + for (int i = 0; i < section.length; i++) { + mSectionIndices[total + i] = s; + } + total += section.length; + } + } + + protected int getItemViewInternalType(int position) { + final int section = getAdapterPositionSection(position); + final Section sectionObject = mSections.get(section); + final int sectionPosition = position - sectionObject.position; + + return getItemViewInternalType(section, sectionPosition); + } + + private int getItemViewInternalType(int section, int position) { + return position == 0 ? TYPE_HEADER : TYPE_ITEM; + } + + static private int internalViewType(int type) { + return type & 0xFF; + } + + static private int externalViewType(int type) { + return type >> 8; + } + + @Override + final public int getItemCount() { + if (mSections == null) { + calculateSections(); + } + return mTotalItemNumber; + } + + @NonNull + @Override + final public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final int internalType = internalViewType(viewType); + final int externalType = externalViewType(viewType); + + switch (internalType) { + case TYPE_HEADER: + return onCreateHeaderViewHolder(parent, externalType); + case TYPE_ITEM: + return onCreateItemViewHolder(parent, externalType); + default: + throw new InvalidParameterException("Invalid viewType: " + viewType); + } + } + + @Override + final public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + if (mSections == null) { + calculateSections(); + } + + final int section = mSectionIndices[position]; + final int internalType = internalViewType(holder.getItemViewType()); + final int externalType = externalViewType(holder.getItemViewType()); + + switch (internalType) { + case TYPE_HEADER: + onBindHeaderViewHolder((HeaderViewHolder)holder, section); + break; + case TYPE_ITEM: + final ItemViewHolder itemHolder = (ItemViewHolder)holder; + final int offset = getItemSectionOffset(section, position); + onBindItemViewHolder((ItemViewHolder)holder, section, offset); + break; + default: + throw new InvalidParameterException("invalid viewType: " + internalType); + } + } + + @Override + final public int getItemViewType(int position) { + final int section = getAdapterPositionSection(position); + final Section sectionObject = mSections.get(section); + final int sectionPosition = position - sectionObject.position; + final int internalType = getItemViewInternalType(section, sectionPosition); + int externalType = 0; + + switch (internalType) { + case TYPE_HEADER: + externalType = getSectionHeaderViewType(section); + break; + case TYPE_ITEM: + externalType = getSectionItemViewType(section, sectionPosition - 1); + break; + } + + return ((externalType & 0xFF) << 8) | (internalType & 0xFF); + } + + // Helpers + private int getItemSectionHeaderPosition(int position) { + return getSectionHeaderPosition(getAdapterPositionSection(position)); + } + + private int getAdapterPosition(int section, int offset) { + if (mSections == null) { + calculateSections(); + } + + if (section < 0) { + throw new IndexOutOfBoundsException("section " + section + " < 0"); + } + + if (section >= mSections.size()) { + throw new IndexOutOfBoundsException("section " + section + " >=" + mSections.size()); + } + + final Section sectionObject = mSections.get(section); + return sectionObject.position + offset; + } + + /** + * Given a section and an adapter position get the offset of an item + * inside section. + * + * @param section section to query + * @param position adapter position + * @return The item offset inside the section. + */ + public int getItemSectionOffset(int section, int position) { + if (mSections == null) { + calculateSections(); + } + + if (section < 0) { + throw new IndexOutOfBoundsException("section " + section + " < 0"); + } + + if (section >= mSections.size()) { + throw new IndexOutOfBoundsException("section " + section + " >=" + mSections.size()); + } + + final Section sectionObject = mSections.get(section); + final int localPosition = position - sectionObject.position; + if (localPosition >= sectionObject.length) { + throw new IndexOutOfBoundsException("localPosition: " + localPosition + " >=" + sectionObject.length); + } + + return localPosition - 1; + } + + /** + * Returns the section index having item or header with provided + * provider position. + * + * @param position adapter position + * @return The section containing provided adapter position. + */ + public int getAdapterPositionSection(int position) { + if (mSections == null) { + calculateSections(); + } + + if (getItemCount() == 0) { + return NO_POSITION; + } + + if (position < 0) { + throw new IndexOutOfBoundsException("position " + position + " < 0"); + } + + if (position >= getItemCount()) { + throw new IndexOutOfBoundsException("position " + position + " >=" + getItemCount()); + } + + return mSectionIndices[position]; + } + + /** + * Returns the adapter position for given section header. Use + * this only for {@link RecyclerView#scrollToPosition(int)} or similar functions. + * Never directly manipulate adapter items using this position. + * + * @param section section to query + * @return The adapter position. + */ + public int getSectionHeaderPosition(int section) { + return getAdapterPosition(section, 0); + } + + /** + * Returns the adapter position for given section and + * offset. Use this only for {@link RecyclerView#scrollToPosition(int)} + * or similar functions. Never directly manipulate adapter items using this position. + * + * @param section section to query + * @param position item position inside the section + * @return The adapter position. + */ + public int getSectionItemPosition(int section, int position) { + return getAdapterPosition(section, position + 1); + } + + // Overrides + /** + * Returns the total number of sections in the data set held by the adapter. + * + * @return The total number of section in this adapter. + */ + public int getSectionCount() { + return 0; + } + + /** + * Returns the number of items in the section. + * + * @param section section to query + * @return The total number of items in the section. + */ + public int getSectionItemCount(int section) { + return 0; + } + + /** + * Return the view type of the section header for the purposes + * of view recycling. + * + *

The default implementation of this method returns 0, making the assumption of + * a single view type for the headers. Unlike ListView adapters, types need not + * be contiguous. Consider using id resources to uniquely identify item view types. + * + * @param section section to query + * @return integer value identifying the type of the view needed to represent the header in + * section. Type codes need not be contiguous. + */ + public int getSectionHeaderViewType(int section) { + return 0; + } + + /** + * Return the view type of the item at position in section for + * the purposes of view recycling. + * + *

The default implementation of this method returns 0, making the assumption of + * a single view type for the adapter. Unlike ListView adapters, types need not + * be contiguous. Consider using id resources to uniquely identify item view types. + * + * @param section section to query + * @param offset section position to query + * @return integer value identifying the type of the view needed to represent the item at + * position in section. Type codes need not be + * contiguous. + */ + public int getSectionItemViewType(int section, int offset) { + return 0; + } + + /** + * Returns true if header in section is sticky. + * + * @param section section to query + * @return true if section header is sticky. + */ + public boolean isSectionHeaderSticky(int section) { + return true; + } + + /** + * Called when RecyclerView needs a new {@link HeaderViewHolder} of the given type to represent + * a header. + *

+ * This new HeaderViewHolder should be constructed with a new View that can represent the headers + * of the given type. You can either create a new View manually or inflate it from an XML + * layout file. + *

+ * The new HeaderViewHolder will be used to display items of the adapter using + * {@link #onBindHeaderViewHolder(HeaderViewHolder, int)}. Since it will be re-used to display + * different items in the data set, it is a good idea to cache references to sub views of + * the View to avoid unnecessary {@link View#findViewById(int)} calls. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param headerType The view type of the new View. + * + * @return A new ViewHolder that holds a View of the given view type. + * @see #getSectionHeaderViewType(int) + * @see #onBindHeaderViewHolder(HeaderViewHolder, int) + */ + public abstract HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType); + + /** + * Called when RecyclerView needs a new {@link ItemViewHolder} of the given type to represent + * an item. + *

+ * This new ViewHolder should be constructed with a new View that can represent the items + * of the given type. You can either create a new View manually or inflate it from an XML + * layout file. + *

+ * The new ViewHolder will be used to display items of the adapter using + * {@link #onBindItemViewHolder(ItemViewHolder, int, int)}. Since it will be re-used to display + * different items in the data set, it is a good idea to cache references to sub views of + * the View to avoid unnecessary {@link View#findViewById(int)} calls. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param itemType The view type of the new View. + * + * @return A new ViewHolder that holds a View of the given view type. + * @see #getSectionItemViewType(int, int) + * @see #onBindItemViewHolder(ItemViewHolder, int, int) + */ + public abstract ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType); + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the {@link HeaderViewHolder#itemView} to reflect the header at the given + * position. + *

+ * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method + * again if the position of the header changes in the data set unless the header itself is + * invalidated or the new position cannot be determined. For this reason, you should only + * use the section parameter while acquiring the + * related header data inside this method and should not keep a copy of it. If you need the + * position of a header later on (e.g. in a click listener), use + * {@link HeaderViewHolder#getAdapterPosition()} which will have the updated adapter + * position. Then you can use {@link #getAdapterPositionSection(int)} to get section index. + * + * + * @param viewHolder The ViewHolder which should be updated to represent the contents of the + * header at the given position in the data set. + * @param section The index of the section. + */ + public abstract void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int section); + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the {@link ItemViewHolder#itemView} to reflect the item at the given + * position. + *

+ * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method + * again if the position of the item changes in the data set unless the item itself is + * invalidated or the new position cannot be determined. For this reason, you should only + * use the offset and section parameters while acquiring the + * related data item inside this method and should not keep a copy of it. If you need the + * position of an item later on (e.g. in a click listener), use + * {@link ItemViewHolder#getAdapterPosition()} which will have the updated adapter + * position. Then you can use {@link #getAdapterPositionSection(int)} and + * {@link #getItemSectionOffset(int, int)} + * + * + * @param viewHolder The ViewHolder which should be updated to represent the contents of the + * item at the given position in the data set. + * @param section The index of the section. + * @param offset The position of the item within the section. + */ + public abstract void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset); + + // Notify + /** + * Notify any registered observers that the data set has changed. + * + *

There are two different classes of data change events, item changes and structural + * changes. Item changes are when a single item has its data updated but no positional + * changes have occurred. Structural changes are when items are inserted, removed or moved + * within the data set.

+ * + *

This event does not specify what about the data set has changed, forcing + * any observers to assume that all existing items and structure may no longer be valid. + * LayoutManagers will be forced to fully rebind and relayout all visible views.

+ * + *

RecyclerView will attempt to synthesize visible structural change events + * for adapters that report that they have {@link #hasStableIds() stable IDs} when + * this method is used. This can help for the purposes of animation and visual + * object persistence but individual item views will still need to be rebound + * and relaid out.

+ * + *

If you are writing an adapter it will always be more efficient to use the more + * specific change events if you can. Rely on notifyDataSetChanged() + * as a last resort.

+ * + * @see #notifySectionDataSetChanged(int) + * @see #notifySectionHeaderChanged(int) + * @see #notifySectionItemChanged(int, int) + * @see #notifySectionInserted(int) + * @see #notifySectionItemInserted(int, int) + * @see #notifySectionItemRangeInserted(int, int, int) + * @see #notifySectionRemoved(int) + * @see #notifySectionItemRemoved(int, int) + * @see #notifySectionItemRangeRemoved(int, int, int) + */ + public void notifyAllSectionsDataSetChanged() { + calculateSections(); + notifyDataSetChanged(); + } + + public void notifySectionDataSetChanged(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeChanged(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionHeaderChanged(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeChanged(sectionObject.position, 1); + } + } + + public void notifySectionItemChanged(int section, int position) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + notifyItemChanged(sectionObject.position + position + 1); + } + } + + public void notifySectionInserted(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeInserted(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionItemInserted(int section, int position) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + notifyItemInserted(sectionObject.position + position + 1); + } + } + + public void notifySectionItemRangeInserted(int section, int position, int count) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + if (position + count > sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + (position + count) + ", size is " + sectionObject.itemNumber); + } + + notifyItemRangeInserted(sectionObject.position + position + 1, count); + } + } + + public void notifySectionRemoved(int section) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + calculateSections(); + notifyItemRangeRemoved(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionItemRemoved(int section, int position) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + calculateSections(); + notifyItemRemoved(sectionObject.position + position + 1); + } + } + + public void notifySectionItemRangeRemoved(int section, int position, int count) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + if (position + count > sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + (position + count) + ", size is " + sectionObject.itemNumber); + } + + calculateSections(); + notifyItemRangeRemoved(sectionObject.position + position + 1, count); + } + } +} diff --git a/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java b/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java new file mode 100644 index 0000000000000000000000000000000000000000..24aa99eea59d2da90c485965a5a22f4562d511fd --- /dev/null +++ b/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java @@ -0,0 +1,1372 @@ +package com.codewaves.stickyheadergrid; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; +import static com.codewaves.stickyheadergrid.StickyHeaderGridAdapter.TYPE_HEADER; +import static com.codewaves.stickyheadergrid.StickyHeaderGridAdapter.TYPE_ITEM; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Created by Sergej Kravcenko on 4/24/2017. + * Copyright (c) 2017 Sergej Kravcenko + */ + +@SuppressWarnings({"unused", "WeakerAccess"}) +public class StickyHeaderGridLayoutManager extends RecyclerView.LayoutManager implements RecyclerView.SmoothScroller.ScrollVectorProvider { + public static final String TAG = "StickyLayoutManager"; + + private static final int DEFAULT_ROW_COUNT = 16; + + private int mSpanCount; + private SpanSizeLookup mSpanSizeLookup = new DefaultSpanSizeLookup(); + + private StickyHeaderGridAdapter mAdapter; + + private int mHeadersStartPosition; + + private View mFloatingHeaderView; + private int mFloatingHeaderPosition; + private int mStickOffset; + private int mAverageHeaderHeight; + private int mHeaderOverlapMargin; + + private HeaderStateChangeListener mHeaderStateListener; + private int mStickyHeaderSection = NO_POSITION; + private View mStickyHeaderView; + private HeaderState mStickyHeadeState; + + private View mFillViewSet[]; + + private SavedState mPendingSavedState; + private int mPendingScrollPosition = NO_POSITION; + private int mPendingScrollPositionOffset; + private AnchorPosition mAnchor = new AnchorPosition(); + + private final FillResult mFillResult = new FillResult(); + private ArrayList mLayoutRows = new ArrayList<>(DEFAULT_ROW_COUNT); + + public enum HeaderState { + NORMAL, + STICKY, + PUSHED + } + + /** + * The interface to be implemented by listeners to header events from this + * LayoutManager. + */ + public interface HeaderStateChangeListener { + /** + * Called when a section header state changes. The position can be HeaderState.NORMAL, + * HeaderState.STICKY, HeaderState.PUSHED. + * + *

+ *

    + *
  • NORMAL - the section header is invisible or has normal position
  • + *
  • STICKY - the section header is sticky at the top of RecyclerView
  • + *
  • PUSHED - the section header is sticky and pushed up by next header
  • + *
0) { + state.mAnchorSection = mAnchor.section; + state.mAnchorItem = mAnchor.item; + state.mAnchorOffset = mAnchor.offset; + } + else { + state.invalidateAnchor(); + } + + return state; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (state instanceof SavedState) { + mPendingSavedState = (SavedState) state; + requestLayout(); + } + else { + Log.d(TAG, "invalid saved state class"); + } + } + + @Override + public boolean checkLayoutParams(RecyclerView.LayoutParams lp) { + return lp instanceof LayoutParams; + } + + @Override + public boolean canScrollVertically() { + return true; + } + + /** + *

Scroll the RecyclerView to make the position visible.

+ * + *

RecyclerView will scroll the minimum amount that is necessary to make the + * target position visible. + * + *

Note that scroll position change will not be reflected until the next layout call.

+ * + * @param position Scroll to this adapter position + */ + @Override + public void scrollToPosition(int position) { + if (position < 0 || position > getItemCount()) { + throw new IndexOutOfBoundsException("adapter position out of range"); + } + + mPendingScrollPosition = position; + mPendingScrollPositionOffset = 0; + if (mPendingSavedState != null) { + mPendingSavedState.invalidateAnchor(); + } + requestLayout(); + } + + private int getExtraLayoutSpace(RecyclerView.State state) { + if (state.hasTargetScrollPosition()) { + return getHeight(); + } + else { + return 0; + } + } + + @Override + public void smoothScrollToPosition(final RecyclerView recyclerView, RecyclerView.State state, int position) { + final LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext()) { + @Override + public int calculateDyToMakeVisible(View view, int snapPreference) { + final RecyclerView.LayoutManager layoutManager = getLayoutManager(); + if (layoutManager == null || !layoutManager.canScrollVertically()) { + return 0; + } + + final int adapterPosition = getPosition(view); + final int topOffset = getPositionSectionHeaderHeight(adapterPosition); + final int top = layoutManager.getDecoratedTop(view); + final int bottom = layoutManager.getDecoratedBottom(view); + final int start = layoutManager.getPaddingTop() + topOffset; + final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom(); + return calculateDtToFit(top, bottom, start, end, snapPreference); + } + }; + linearSmoothScroller.setTargetPosition(position); + startSmoothScroll(linearSmoothScroller); + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (getChildCount() == 0) { + return null; + } + + final LayoutRow firstRow = getFirstVisibleRow(); + if (firstRow == null) { + return null; + } + + return new PointF(0, targetPosition - firstRow.adapterPosition); + } + + private int getAdapterPositionFromAnchor(AnchorPosition anchor) { + if (anchor.section < 0 || anchor.section >= mAdapter.getSectionCount()) { + anchor.reset(); + return NO_POSITION; + } + else if (anchor.item < 0 || anchor.item >= mAdapter.getSectionItemCount(anchor.section)) { + anchor.offset = 0; + return mAdapter.getSectionHeaderPosition(anchor.section); + } + return mAdapter.getSectionItemPosition(anchor.section, anchor.item); + } + + private int getAdapterPositionChecked(int section, int offset) { + if (section < 0 || section >= mAdapter.getSectionCount()) { + return NO_POSITION; + } + else if (offset < 0 || offset >= mAdapter.getSectionItemCount(section)) { + return mAdapter.getSectionHeaderPosition(section); + } + return mAdapter.getSectionItemPosition(section, offset); + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + if (mAdapter == null || state.getItemCount() == 0) { + removeAndRecycleAllViews(recycler); + clearState(); + return; + } + + int pendingAdapterPosition; + int pendingAdapterOffset; + if (mPendingScrollPosition >= 0) { + pendingAdapterPosition = mPendingScrollPosition; + pendingAdapterOffset = mPendingScrollPositionOffset; + } + else if (mPendingSavedState != null && mPendingSavedState.hasValidAnchor()) { + pendingAdapterPosition = getAdapterPositionChecked(mPendingSavedState.mAnchorSection, mPendingSavedState.mAnchorItem); + pendingAdapterOffset = mPendingSavedState.mAnchorOffset; + mPendingSavedState = null; + } + else { + pendingAdapterPosition = getAdapterPositionFromAnchor(mAnchor); + pendingAdapterOffset = mAnchor.offset; + } + + if (pendingAdapterPosition < 0 || pendingAdapterPosition >= state.getItemCount()) { + pendingAdapterPosition = 0; + pendingAdapterOffset = 0; + mPendingScrollPosition = NO_POSITION; + } + + if (pendingAdapterOffset > 0) { + pendingAdapterOffset = 0; + } + + detachAndScrapAttachedViews(recycler); + clearState(); + + // Make sure mFirstViewPosition is the start of the row + pendingAdapterPosition = findFirstRowItem(pendingAdapterPosition); + + int left = getPaddingLeft(); + int right = getWidth() - getPaddingRight(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + int totalHeight = 0; + + int adapterPosition = pendingAdapterPosition; + int top = getPaddingTop() + pendingAdapterOffset; + while (true) { + if (adapterPosition >= state.getItemCount()) { + break; + } + + int bottom; + final int viewType = mAdapter.getItemViewInternalType(adapterPosition); + if (viewType == TYPE_HEADER) { + final View v = recycler.getViewForPosition(adapterPosition); + addView(v); + measureChildWithMargins(v, 0, 0); + + int height = getDecoratedMeasuredHeight(v); + final int margin = height >= mHeaderOverlapMargin ? mHeaderOverlapMargin : height; + bottom = top + height; + layoutDecorated(v, left, top, right, bottom); + + bottom -= margin; + height -= margin; + mLayoutRows.add(new LayoutRow(v, adapterPosition, 1, top, bottom)); + adapterPosition++; + mAverageHeaderHeight = height; + } + else { + final FillResult result = fillBottomRow(recycler, state, adapterPosition, top); + bottom = top + result.height; + mLayoutRows.add(new LayoutRow(result.adapterPosition, result.length, top, bottom)); + adapterPosition += result.length; + } + top = bottom; + + if (bottom >= recyclerBottom + getExtraLayoutSpace(state)) { + break; + } + } + + if (getBottomRow().bottom < recyclerBottom) { + scrollVerticallyBy(getBottomRow().bottom - recyclerBottom, recycler, state); + } + else { + clearViewsAndStickHeaders(recycler, state, false); + } + + // If layout was caused by the pending scroll, adjust top item position and move it under sticky header + if (mPendingScrollPosition >= 0) { + mPendingScrollPosition = NO_POSITION; + + final int topOffset = getPositionSectionHeaderHeight(pendingAdapterPosition); + if (topOffset != 0) { + scrollVerticallyBy(-topOffset, recycler, state); + } + } + } + + @Override + public void onLayoutCompleted(RecyclerView.State state) { + super.onLayoutCompleted(state); + mPendingSavedState = null; + } + + private int getPositionSectionHeaderHeight(int adapterPosition) { + final int section = mAdapter.getAdapterPositionSection(adapterPosition); + if (section >= 0 && mAdapter.isSectionHeaderSticky(section)) { + final int offset = mAdapter.getItemSectionOffset(section, adapterPosition); + if (offset >= 0) { + final int headerAdapterPosition = mAdapter.getSectionHeaderPosition(section); + if (mFloatingHeaderView != null && headerAdapterPosition == mFloatingHeaderPosition) { + return Math.max(0, getDecoratedMeasuredHeight(mFloatingHeaderView) - mHeaderOverlapMargin); + } + else { + final LayoutRow header = getHeaderRow(headerAdapterPosition); + if (header != null) { + return header.getHeight(); + } + else { + // Fall back to cached header size, can be incorrect + return mAverageHeaderHeight; + } + } + } + } + + return 0; + } + + private int findFirstRowItem(int adapterPosition) { + final int section = mAdapter.getAdapterPositionSection(adapterPosition); + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + while (sectionPosition > 0 && mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount) != 0) { + sectionPosition--; + adapterPosition--; + } + + return adapterPosition; + } + + private int getSpanWidth(int recyclerWidth, int spanIndex, int spanSize) { + final int spanWidth = recyclerWidth / mSpanCount; + final int spanWidthReminder = recyclerWidth - spanWidth * mSpanCount; + final int widthCorrection = Math.min(Math.max(0, spanWidthReminder - spanIndex), spanSize); + + return spanWidth * spanSize + widthCorrection; + } + + private int getSpanLeft(int recyclerWidth, int spanIndex) { + final int spanWidth = recyclerWidth / mSpanCount; + final int spanWidthReminder = recyclerWidth - spanWidth * mSpanCount; + final int widthCorrection = Math.min(spanWidthReminder, spanIndex); + + return spanWidth * spanIndex + widthCorrection; + } + + private FillResult fillBottomRow(RecyclerView.Recycler recycler, RecyclerView.State state, int position, int top) { + final int recyclerWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + final int section = mAdapter.getAdapterPositionSection(position); + int adapterPosition = position; + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + int spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + int spanIndex = mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount); + int count = 0; + int maxHeight = 0; + + // Create phase + Arrays.fill(mFillViewSet, null); + while (spanIndex + spanSize <= mSpanCount) { + // Create view and fill layout params + final int spanWidth = getSpanWidth(recyclerWidth, spanIndex, spanSize); + final View v = recycler.getViewForPosition(adapterPosition); + final LayoutParams params = (LayoutParams)v.getLayoutParams(); + params.mSpanIndex = spanIndex; + params.mSpanSize = spanSize; + + addView(v, mHeadersStartPosition); + mHeadersStartPosition++; + measureChildWithMargins(v, recyclerWidth - spanWidth, 0); + mFillViewSet[count] = v; + count++; + + final int height = getDecoratedMeasuredHeight(v); + if (maxHeight < height) { + maxHeight = height; + } + + // Check next + adapterPosition++; + sectionPosition++; + if (sectionPosition >= mAdapter.getSectionItemCount(section)) { + break; + } + + spanIndex += spanSize; + spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + } + + // Layout phase + int left = getPaddingLeft(); + for (int i = 0; i < count; ++i) { + final View v = mFillViewSet[i]; + final int height = getDecoratedMeasuredHeight(v); + final int width = getDecoratedMeasuredWidth(v); + layoutDecorated(v, left, top, left + width, top + height); + left += width; + } + + mFillResult.edgeView = mFillViewSet[count - 1]; + mFillResult.adapterPosition = position; + mFillResult.length = count; + mFillResult.height = maxHeight; + + return mFillResult; + } + + private FillResult fillTopRow(RecyclerView.Recycler recycler, RecyclerView.State state, int position, int top) { + final int recyclerWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + final int section = mAdapter.getAdapterPositionSection(position); + int adapterPosition = position; + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + int spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + int spanIndex = mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount); + int count = 0; + int maxHeight = 0; + + Arrays.fill(mFillViewSet, null); + while (spanIndex >= 0) { + // Create view and fill layout params + final int spanWidth = getSpanWidth(recyclerWidth, spanIndex, spanSize); + final View v = recycler.getViewForPosition(adapterPosition); + final LayoutParams params = (LayoutParams)v.getLayoutParams(); + params.mSpanIndex = spanIndex; + params.mSpanSize = spanSize; + + addView(v, 0); + mHeadersStartPosition++; + measureChildWithMargins(v, recyclerWidth - spanWidth, 0); + mFillViewSet[count] = v; + count++; + + final int height = getDecoratedMeasuredHeight(v); + if (maxHeight < height) { + maxHeight = height; + } + + // Check next + adapterPosition--; + sectionPosition--; + if (sectionPosition < 0) { + break; + } + + spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + spanIndex -= spanSize; + } + + // Layout phase + int left = getPaddingLeft(); + for (int i = count - 1; i >= 0; --i) { + final View v = mFillViewSet[i]; + final int height = getDecoratedMeasuredHeight(v); + final int width = getDecoratedMeasuredWidth(v); + layoutDecorated(v, left, top - maxHeight, left + width, top - (maxHeight - height)); + left += width; + } + + mFillResult.edgeView = mFillViewSet[count - 1]; + mFillResult.adapterPosition = adapterPosition + 1; + mFillResult.length = count; + mFillResult.height = maxHeight; + + return mFillResult; + } + + private void clearHiddenRows(RecyclerView.Recycler recycler, RecyclerView.State state, boolean top) { + if (mLayoutRows.size() <= 0) { + return; + } + + final int recyclerTop = getPaddingTop(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + + if (top) { + LayoutRow row = getTopRow(); + while (row.bottom < recyclerTop - getExtraLayoutSpace(state) || row.top > recyclerBottom) { + if (row.header) { + removeAndRecycleViewAt(mHeadersStartPosition + (mFloatingHeaderView != null ? 1 : 0), recycler); + } + else { + for (int i = 0; i < row.length; ++i) { + removeAndRecycleViewAt(0, recycler); + mHeadersStartPosition--; + } + } + mLayoutRows.remove(0); + row = getTopRow(); + } + } + else { + LayoutRow row = getBottomRow(); + while (row.bottom < recyclerTop || row.top > recyclerBottom + getExtraLayoutSpace(state)) { + if (row.header) { + removeAndRecycleViewAt(getChildCount() - 1, recycler); + } + else { + for (int i = 0; i < row.length; ++i) { + removeAndRecycleViewAt(mHeadersStartPosition - 1, recycler); + mHeadersStartPosition--; + } + } + mLayoutRows.remove(mLayoutRows.size() - 1); + row = getBottomRow(); + } + } + } + + private void clearViewsAndStickHeaders(RecyclerView.Recycler recycler, RecyclerView.State state, boolean top) { + clearHiddenRows(recycler, state, top); + if (getChildCount() > 0) { + stickTopHeader(recycler); + } + updateTopPosition(); + } + + private LayoutRow getBottomRow() { + return mLayoutRows.get(mLayoutRows.size() - 1); + } + + private LayoutRow getTopRow() { + return mLayoutRows.get(0); + } + + private void offsetRowsVertical(int offset) { + for (LayoutRow row : mLayoutRows) { + row.top += offset; + row.bottom += offset; + } + offsetChildrenVertical(offset); + } + + private void addRow(RecyclerView.Recycler recycler, RecyclerView.State state, boolean isTop, int adapterPosition, int top) { + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + + // Reattach floating header if needed + if (isTop && mFloatingHeaderView != null && adapterPosition == mFloatingHeaderPosition) { + removeFloatingHeader(recycler); + } + + final int viewType = mAdapter.getItemViewInternalType(adapterPosition); + if (viewType == TYPE_HEADER) { + final View v = recycler.getViewForPosition(adapterPosition); + if (isTop) { + addView(v, mHeadersStartPosition); + } + else { + addView(v); + } + measureChildWithMargins(v, 0, 0); + final int height = getDecoratedMeasuredHeight(v); + final int margin = height >= mHeaderOverlapMargin ? mHeaderOverlapMargin : height; + if (isTop) { + layoutDecorated(v, left, top - height + margin, right, top + margin); + mLayoutRows.add(0, new LayoutRow(v, adapterPosition, 1, top - height + margin, top)); + } + else { + layoutDecorated(v, left, top, right, top + height); + mLayoutRows.add(new LayoutRow(v, adapterPosition, 1, top, top + height - margin)); + } + mAverageHeaderHeight = height - margin; + } + else { + if (isTop) { + final FillResult result = fillTopRow(recycler, state, adapterPosition, top); + mLayoutRows.add(0, new LayoutRow(result.adapterPosition, result.length, top - result.height, top)); + } + else { + final FillResult result = fillBottomRow(recycler, state, adapterPosition, top); + mLayoutRows.add(new LayoutRow(result.adapterPosition, result.length, top, top + result.height)); + } + } + } + + private void addOffScreenRows(RecyclerView.Recycler recycler, RecyclerView.State state, int recyclerTop, int recyclerBottom, boolean bottom) { + if (bottom) { + // Bottom + while (true) { + final LayoutRow bottomRow = getBottomRow(); + final int adapterPosition = bottomRow.adapterPosition + bottomRow.length; + if (bottomRow.bottom >= recyclerBottom + getExtraLayoutSpace(state) || adapterPosition >= state.getItemCount()) { + break; + } + addRow(recycler, state, false, adapterPosition, bottomRow.bottom); + } + } + else { + // Top + while (true) { + final LayoutRow topRow = getTopRow(); + final int adapterPosition = topRow.adapterPosition - 1; + if (topRow.top < recyclerTop - getExtraLayoutSpace(state) || adapterPosition < 0) { + break; + } + addRow(recycler, state, true, adapterPosition, topRow.top); + } + } + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { + if (getChildCount() == 0) { + return 0; + } + + int scrolled = 0; + int left = getPaddingLeft(); + int right = getWidth() - getPaddingRight(); + final int recyclerTop = getPaddingTop(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + + // If we have simple header stick, offset it back + final int firstHeader = getFirstVisibleSectionHeader(); + if (firstHeader != NO_POSITION) { + mLayoutRows.get(firstHeader).headerView.offsetTopAndBottom(-mStickOffset); + } + + if (dy >= 0) { + // Up + while (scrolled < dy) { + final LayoutRow bottomRow = getBottomRow(); + final int scrollChunk = -Math.min(Math.max(bottomRow.bottom - recyclerBottom, 0), dy - scrolled); + + offsetRowsVertical(scrollChunk); + scrolled -= scrollChunk; + + final int adapterPosition = bottomRow.adapterPosition + bottomRow.length; + if (scrolled >= dy || adapterPosition >= state.getItemCount()) { + break; + } + + addRow(recycler, state, false, adapterPosition, bottomRow.bottom); + } + } + else { + // Down + while (scrolled > dy) { + final LayoutRow topRow = getTopRow(); + final int scrollChunk = Math.min(Math.max(-topRow.top + recyclerTop, 0), scrolled - dy); + + offsetRowsVertical(scrollChunk); + scrolled -= scrollChunk; + + final int adapterPosition = topRow.adapterPosition - 1; + if (scrolled <= dy || adapterPosition >= state.getItemCount() || adapterPosition < 0) { + break; + } + + addRow(recycler, state, true, adapterPosition, topRow.top); + } + } + + // Fill extra offscreen rows for smooth scroll + if (scrolled == dy) { + addOffScreenRows(recycler, state, recyclerTop, recyclerBottom, dy >= 0); + } + + clearViewsAndStickHeaders(recycler, state, dy >= 0); + return scrolled; + } + + /** + * Returns first visible item excluding headers. + * + * @param visibleTop Whether item top edge should be visible or not + * @return The first visible item adapter position closest to top of the layout. + */ + public int getFirstVisibleItemPosition(boolean visibleTop) { + return getFirstVisiblePosition(TYPE_ITEM, visibleTop); + } + + /** + * Returns last visible item excluding headers. + * + * @return The last visible item adapter position closest to bottom of the layout. + */ + public int getLastVisibleItemPosition() { + return getLastVisiblePosition(TYPE_ITEM); + } + + /** + * Returns first visible header. + * + * @param visibleTop Whether header top edge should be visible or not + * @return The first visible header adapter position closest to top of the layout. + */ + public int getFirstVisibleHeaderPosition(boolean visibleTop) { + return getFirstVisiblePosition(TYPE_HEADER, visibleTop); + } + + /** + * Returns last visible header. + * + * @return The last visible header adapter position closest to bottom of the layout. + */ + public int getLastVisibleHeaderPosition() { + return getLastVisiblePosition(TYPE_HEADER); + } + + private int getFirstVisiblePosition(int type, boolean visibleTop) { + if (type == TYPE_ITEM && mHeadersStartPosition <= 0) { + return NO_POSITION; + } + else if (type == TYPE_HEADER && mHeadersStartPosition >= getChildCount()) { + return NO_POSITION; + } + + int viewFrom = type == TYPE_ITEM ? 0 : mHeadersStartPosition; + int viewTo = type == TYPE_ITEM ? mHeadersStartPosition : getChildCount(); + final int recyclerTop = getPaddingTop(); + for (int i = viewFrom; i < viewTo; ++i) { + final View v = getChildAt(i); + final int adapterPosition = getPosition(v); + final int headerHeight = getPositionSectionHeaderHeight(adapterPosition); + final int top = getDecoratedTop(v); + final int bottom = getDecoratedBottom(v); + + if (visibleTop) { + if (top >= recyclerTop + headerHeight) { + return adapterPosition; + } + } + else { + if (bottom >= recyclerTop + headerHeight) { + return adapterPosition; + } + } + } + + return NO_POSITION; + } + + private int getLastVisiblePosition(int type) { + if (type == TYPE_ITEM && mHeadersStartPosition <= 0) { + return NO_POSITION; + } + else if (type == TYPE_HEADER && mHeadersStartPosition >= getChildCount()) { + return NO_POSITION; + } + + int viewFrom = type == TYPE_ITEM ? mHeadersStartPosition - 1 : getChildCount() - 1; + int viewTo = type == TYPE_ITEM ? 0 : mHeadersStartPosition; + final int recyclerBottom = getHeight() - getPaddingBottom(); + for (int i = viewFrom; i >= viewTo; --i) { + final View v = getChildAt(i); + final int top = getDecoratedTop(v); + + if (top < recyclerBottom) { + return getPosition(v); + } + } + + return NO_POSITION; + } + + private LayoutRow getFirstVisibleRow() { + final int recyclerTop = getPaddingTop(); + for (LayoutRow row : mLayoutRows) { + if (row.bottom > recyclerTop) { + return row; + } + } + return null; + } + + private int getFirstVisibleSectionHeader() { + final int recyclerTop = getPaddingTop(); + + int header = NO_POSITION; + for (int i = 0, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header) { + header = i; + } + if (row.bottom > recyclerTop) { + return header; + } + } + return NO_POSITION; + } + + private LayoutRow getNextVisibleSectionHeader(int headerFrom) { + for (int i = headerFrom + 1, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header) { + return row; + } + } + return null; + } + + private LayoutRow getHeaderRow(int adapterPosition) { + for (int i = 0, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header && row.adapterPosition == adapterPosition) { + return row; + } + } + return null; + } + + private void removeFloatingHeader(RecyclerView.Recycler recycler) { + if (mFloatingHeaderView == null) { + return; + } + + final View view = mFloatingHeaderView; + mFloatingHeaderView = null; + mFloatingHeaderPosition = NO_POSITION; + removeAndRecycleView(view, recycler); + } + + private void onHeaderChanged(int section, View view, HeaderState state, int pushOffset) { + if (mStickyHeaderSection != NO_POSITION && section != mStickyHeaderSection) { + onHeaderUnstick(); + } + + final boolean headerStateChanged = mStickyHeaderSection != section || !mStickyHeadeState.equals(state) || state.equals(HeaderState.PUSHED); + + mStickyHeaderSection = section; + mStickyHeaderView = view; + mStickyHeadeState = state; + + if (headerStateChanged && mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(section, view, state, pushOffset); + } + } + + private void onHeaderUnstick() { + if (mStickyHeaderSection != NO_POSITION) { + if (mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(mStickyHeaderSection, mStickyHeaderView, HeaderState.NORMAL, 0); + } + mStickyHeaderSection = NO_POSITION; + mStickyHeaderView = null; + mStickyHeadeState = HeaderState.NORMAL; + } + } + + private void stickTopHeader(RecyclerView.Recycler recycler) { + final int firstHeader = getFirstVisibleSectionHeader(); + final int top = getPaddingTop(); + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + + int notifySection = NO_POSITION; + View notifyView = null; + HeaderState notifyState = HeaderState.NORMAL; + int notifyOffset = 0; + + if (firstHeader != NO_POSITION) { + // Top row is header, floating header is not visible, remove + removeFloatingHeader(recycler); + + final LayoutRow firstHeaderRow = mLayoutRows.get(firstHeader); + final int section = mAdapter.getAdapterPositionSection(firstHeaderRow.adapterPosition); + if (mAdapter.isSectionHeaderSticky(section)) { + final LayoutRow nextHeaderRow = getNextVisibleSectionHeader(firstHeader); + int offset = 0; + if (nextHeaderRow != null) { + final int height = firstHeaderRow.getHeight(); + offset = Math.min(Math.max(top - nextHeaderRow.top, -height) + height, height); + } + + mStickOffset = top - firstHeaderRow.top - offset; + firstHeaderRow.headerView.offsetTopAndBottom(mStickOffset); + + onHeaderChanged(section, firstHeaderRow.headerView, offset == 0 ? HeaderState.STICKY : HeaderState.PUSHED, offset); + } + else { + onHeaderUnstick(); + mStickOffset = 0; + } + } + else { + // We don't have first visible sector header in layout, create floating + final LayoutRow firstVisibleRow = getFirstVisibleRow(); + if (firstVisibleRow != null) { + final int section = mAdapter.getAdapterPositionSection(firstVisibleRow.adapterPosition); + if (mAdapter.isSectionHeaderSticky(section)) { + final int headerPosition = mAdapter.getSectionHeaderPosition(section); + if (mFloatingHeaderView == null || mFloatingHeaderPosition != headerPosition) { + removeFloatingHeader(recycler); + + // Create floating header + final View v = recycler.getViewForPosition(headerPosition); + addView(v, mHeadersStartPosition); + measureChildWithMargins(v, 0, 0); + mFloatingHeaderView = v; + mFloatingHeaderPosition = headerPosition; + } + + // Push floating header up, if needed + final int height = getDecoratedMeasuredHeight(mFloatingHeaderView); + int offset = 0; + if (getChildCount() - mHeadersStartPosition > 1) { + final View nextHeader = getChildAt(mHeadersStartPosition + 1); + final int contentHeight = Math.max(0, height - mHeaderOverlapMargin); + offset = Math.max(top - getDecoratedTop(nextHeader), -contentHeight) + contentHeight; + } + + layoutDecorated(mFloatingHeaderView, left, top - offset, right, top + height - offset); + onHeaderChanged(section, mFloatingHeaderView, offset == 0 ? HeaderState.STICKY : HeaderState.PUSHED, offset); + } + else { + onHeaderUnstick(); + } + } + else { + onHeaderUnstick(); + } + } + } + + private void updateTopPosition() { + if (getChildCount() == 0) { + mAnchor.reset(); + } + + final LayoutRow firstVisibleRow = getFirstVisibleRow(); + if (firstVisibleRow != null) { + mAnchor.section = mAdapter.getAdapterPositionSection(firstVisibleRow.adapterPosition); + mAnchor.item = mAdapter.getItemSectionOffset(mAnchor.section, firstVisibleRow.adapterPosition); + mAnchor.offset = Math.min(firstVisibleRow.top - getPaddingTop(), 0); + } + } + + private int getViewType(View view) { + return getItemViewType(view) & 0xFF; + } + + private int getViewType(int position) { + return mAdapter.getItemViewType(position) & 0xFF; + } + + private void clearState() { + mHeadersStartPosition = 0; + mStickOffset = 0; + mFloatingHeaderView = null; + mFloatingHeaderPosition = -1; + mAverageHeaderHeight = 0; + mLayoutRows.clear(); + + if (mStickyHeaderSection != NO_POSITION) { + if (mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(mStickyHeaderSection, mStickyHeaderView, HeaderState.NORMAL, 0); + } + mStickyHeaderSection = NO_POSITION; + mStickyHeaderView = null; + mStickyHeadeState = HeaderState.NORMAL; + } + } + + @Override + public int computeVerticalScrollExtent(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + return Math.abs(getPosition(startChild) - getPosition(endChild)) + 1; + } + + @Override + public int computeVerticalScrollOffset(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + final int recyclerTop = getPaddingTop(); + final LayoutRow topRow = getTopRow(); + final int scrollChunk = Math.max(-topRow.top + recyclerTop, 0); + if (scrollChunk == 0) { + return 0; + } + + final int minPosition = Math.min(getPosition(startChild), getPosition(endChild)); + final int maxPosition = Math.max(getPosition(startChild), getPosition(endChild)); + return Math.max(0, minPosition); + } + + @Override + public int computeVerticalScrollRange(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + return state.getItemCount(); + } + + public static class LayoutParams extends RecyclerView.LayoutParams { + public static final int INVALID_SPAN_ID = -1; + + private int mSpanIndex = INVALID_SPAN_ID; + private int mSpanSize = 0; + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.MarginLayoutParams source) { + super(source); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public LayoutParams(RecyclerView.LayoutParams source) { + super(source); + } + + public int getSpanIndex() { + return mSpanIndex; + } + + public int getSpanSize() { + return mSpanSize; + } + } + + public static final class DefaultSpanSizeLookup extends SpanSizeLookup { + @Override + public int getSpanSize(int section, int position) { + return 1; + } + + @Override + public int getSpanIndex(int section, int position, int spanCount) { + return position % spanCount; + } + } + + /** + * An interface to provide the number of spans each item occupies. + *

+ * Default implementation sets each item to occupy exactly 1 span. + * + * @see StickyHeaderGridLayoutManager#setSpanSizeLookup(StickyHeaderGridLayoutManager.SpanSizeLookup) + */ + public static abstract class SpanSizeLookup { + /** + * Returns the number of span occupied by the item in section at position. + * + * @param section The adapter section of the item + * @param position The adapter position of the item in section + * @return The number of spans occupied by the item at the provided section and position + */ + abstract public int getSpanSize(int section, int position); + + /** + * Returns the final span index of the provided position. + * + *

+ * If you override this method, you need to make sure it is consistent with + * {@link #getSpanSize(int, int)}. StickyHeaderGridLayoutManager does not call this method for + * each item. It is called only for the reference item and rest of the items + * are assigned to spans based on the reference item. For example, you cannot assign a + * position to span 2 while span 1 is empty. + *

+ * + * @param section The adapter section of the item + * @param position The adapter position of the item in section + * @param spanCount The total number of spans in the grid + * @return The final span position of the item. Should be between 0 (inclusive) and + * spanCount(exclusive) + */ + public int getSpanIndex(int section, int position, int spanCount) { + // TODO: cache them? + final int positionSpanSize = getSpanSize(section, position); + if (positionSpanSize >= spanCount) { + return 0; + } + + int spanIndex = 0; + for (int i = 0; i < position; ++i) { + final int spanSize = getSpanSize(section, i); + spanIndex += spanSize; + + if (spanIndex == spanCount) { + spanIndex = 0; + } + else if (spanIndex > spanCount) { + spanIndex = spanSize; + } + } + + if (spanIndex + positionSpanSize <= spanCount) { + return spanIndex; + } + + return 0; + } + } + + public static class SavedState implements Parcelable { + private int mAnchorSection; + private int mAnchorItem; + private int mAnchorOffset; + + public SavedState() { + + } + + SavedState(Parcel in) { + mAnchorSection = in.readInt(); + mAnchorItem = in.readInt(); + mAnchorOffset = in.readInt(); + } + + public SavedState(SavedState other) { + mAnchorSection = other.mAnchorSection; + mAnchorItem = other.mAnchorItem; + mAnchorOffset = other.mAnchorOffset; + } + + boolean hasValidAnchor() { + return mAnchorSection >= 0; + } + + void invalidateAnchor() { + mAnchorSection = NO_POSITION; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mAnchorSection); + dest.writeInt(mAnchorItem); + dest.writeInt(mAnchorOffset); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + private static class LayoutRow { + private boolean header; + private View headerView; + private int adapterPosition; + private int length; + private int top; + private int bottom; + + public LayoutRow(int adapterPosition, int length, int top, int bottom) { + this.header = false; + this.headerView = null; + this.adapterPosition = adapterPosition; + this.length = length; + this.top = top; + this.bottom = bottom; + } + + public LayoutRow(View headerView, int adapterPosition, int length, int top, int bottom) { + this.header = true; + this.headerView = headerView; + this.adapterPosition = adapterPosition; + this.length = length; + this.top = top; + this.bottom = bottom; + } + + int getHeight() { + return bottom - top; + } + } + + private static class FillResult { + private View edgeView; + private int adapterPosition; + private int length; + private int height; + } + + private static class AnchorPosition { + private int section; + private int item; + private int offset; + + public AnchorPosition() { + reset(); + } + + public void reset() { + section = NO_POSITION; + item = 0; + offset = 0; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..7ab97e7b437826fc7a92030db95db1e29b34b937 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java @@ -0,0 +1,196 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.MenuItem; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; +import com.google.android.material.tabs.TabLayout; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; + +public class AllMediaActivity extends PassphraseRequiredActionBarActivity + implements DcEventCenter.DcEventDelegate +{ + + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String CONTACT_ID_EXTRA = "contact_id"; + public static final String FORCE_GALLERY = "force_gallery"; + + static class TabData { + final int title; + final int type1; + final int type2; + final int type3; + TabData(int title, int type1, int type2, int type3) { + this.title = title; + this.type1 = type1; + this.type2 = type2; + this.type3 = type3; + } + }; + + private DcContext dcContext; + private int chatId; + private int contactId; + + private final ArrayList tabs = new ArrayList<>(); + private Toolbar toolbar; + private TabLayout tabLayout; + private ViewPager viewPager; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + dcContext = DcHelper.getContext(this); + } + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + tabs.add(new TabData(R.string.webxdc_apps, DcMsg.DC_MSG_WEBXDC, 0, 0)); + tabs.add(new TabData(R.string.tab_gallery, DcMsg.DC_MSG_IMAGE, DcMsg.DC_MSG_GIF, DcMsg.DC_MSG_VIDEO)); + tabs.add(new TabData(R.string.audio, DcMsg.DC_MSG_AUDIO, DcMsg.DC_MSG_VOICE, 0)); + tabs.add(new TabData(R.string.files, DcMsg.DC_MSG_FILE, 0, 0)); + + setContentView(R.layout.all_media_activity); + + initializeResources(); + + setSupportActionBar(this.toolbar); + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setTitle(isGlobalGallery() ? R.string.menu_all_media : R.string.apps_and_media); + } + + this.tabLayout.setupWithViewPager(viewPager); + this.viewPager.setAdapter(new AllMediaPagerAdapter(getSupportFragmentManager())); + if (getIntent().getBooleanExtra(FORCE_GALLERY, false)) { + this.viewPager.setCurrentItem(1, false); + } + + DcEventCenter eventCenter = DcHelper.getEventCenter(this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(this).removeObservers(this); + super.onDestroy(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + } + + private void initializeResources() { + chatId = getIntent().getIntExtra(CHAT_ID_EXTRA, 0); + contactId = getIntent().getIntExtra(CONTACT_ID_EXTRA, 0); + + if (contactId!=0) { + chatId = dcContext.getChatIdByContactId(contactId); + } + + if(chatId!=0) { + DcChat dcChat = dcContext.getChat(chatId); + if(!dcChat.isMultiUser()) { + final int[] members = dcContext.getChatContacts(chatId); + contactId = members.length>=1? members[0] : 0; + } + } + + this.viewPager = ViewUtil.findById(this, R.id.pager); + this.toolbar = ViewUtil.findById(this, R.id.toolbar); + this.tabLayout = ViewUtil.findById(this, R.id.tab_layout); + } + + private boolean isGlobalGallery() { + return contactId==0 && chatId==0; + } + + private class AllMediaPagerAdapter extends FragmentStatePagerAdapter { + private Object currentFragment = null; + + AllMediaPagerAdapter(FragmentManager fragmentManager) { + super(fragmentManager); + } + + @Override + public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + super.setPrimaryItem(container, position, object); + if (currentFragment != null && currentFragment != object) { + ActionMode action = null; + if (currentFragment instanceof MessageSelectorFragment) { + action = ((MessageSelectorFragment) currentFragment).getActionMode(); + } + if (action != null) { + action.finish(); + } + } + currentFragment = object; + } + + @NonNull + @Override + public Fragment getItem(int position) { + TabData data = tabs.get(position); + Fragment fragment; + Bundle args = new Bundle(); + + if (data.type1 == DcMsg.DC_MSG_IMAGE) { + fragment = new AllMediaGalleryFragment(); + args.putInt(AllMediaGalleryFragment.CHAT_ID_EXTRA, (chatId==0&&!isGlobalGallery())? -1 : chatId); + } else { + fragment = new AllMediaDocumentsFragment(); + args.putInt(AllMediaDocumentsFragment.CHAT_ID_EXTRA, (chatId==0&&!isGlobalGallery())? -1 : chatId); + args.putInt(AllMediaDocumentsFragment.VIEWTYPE1, data.type1); + args.putInt(AllMediaDocumentsFragment.VIEWTYPE2, data.type2); + } + fragment.setArguments(args); + return fragment; + } + + @Override + public int getCount() { + return tabs.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return getString(tabs.get(position).title); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } + + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..c582c72888816f78a6303c8b7012299a816f8a53 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcMsg; +import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; + +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.DocumentView; +import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +class AllMediaDocumentsAdapter extends StickyHeaderGridAdapter { + + private final Context context; + private final ItemClickListener itemClickListener; + private final Set selected; + + private BucketedThreadMedia media; + + private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { + private final DocumentView documentView; + private final AudioView audioView; + private final WebxdcView webxdcView; + private final TextView date; + + public ViewHolder(View v) { + super(v); + documentView = v.findViewById(R.id.document_view); + audioView = v.findViewById(R.id.audio_view); + webxdcView = v.findViewById(R.id.webxdc_view); + date = v.findViewById(R.id.date); + } + } + + private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder { + final TextView textView; + + HeaderHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.label); + } + } + + AllMediaDocumentsAdapter(@NonNull Context context, + BucketedThreadMedia media, + ItemClickListener clickListener) + { + this.context = context; + this.media = media; + this.itemClickListener = clickListener; + this.selected = new HashSet<>(); + } + + public void setMedia(BucketedThreadMedia media) { + this.media = media; + } + + @Override + public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { + return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.contact_selection_list_divider, parent, false)); + } + + @Override + public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) { + return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.profile_document_item, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) { + ((HeaderHolder)viewHolder).textView.setText(media.getName(section)); + } + + @Override + public void onBindItemViewHolder(ItemViewHolder itemViewHolder, int section, int offset) { + ViewHolder viewHolder = ((ViewHolder)itemViewHolder); + DcMsg dcMsg = media.get(section, offset); + Slide slide = MediaUtil.getSlideForMsg(context, dcMsg); + + if (slide != null && slide.hasAudio()) { + viewHolder.documentView.setVisibility(View.GONE); + viewHolder.webxdcView.setVisibility(View.GONE); + + viewHolder.audioView.setVisibility(View.VISIBLE); + viewHolder.audioView.setAudio((AudioSlide)slide, dcMsg.getDuration()); + viewHolder.audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.audioView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); + viewHolder.audioView.disablePlayer(!selected.isEmpty()); + viewHolder.itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.date.setVisibility(View.VISIBLE); + } + else if (slide != null && slide.isWebxdcDocument()) { + viewHolder.audioView.setVisibility(View.GONE); + viewHolder.documentView.setVisibility(View.GONE); + + viewHolder.webxdcView.setVisibility(View.VISIBLE); + viewHolder.webxdcView.setWebxdc(dcMsg, ""); + viewHolder.webxdcView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.webxdcView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); + viewHolder.itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.date.setVisibility(View.GONE); + } + else if (slide != null && slide.hasDocument()) { + viewHolder.audioView.setVisibility(View.GONE); + viewHolder.webxdcView.setVisibility(View.GONE); + + viewHolder.documentView.setVisibility(View.VISIBLE); + viewHolder.documentView.setDocument((DocumentSlide)slide); + viewHolder.documentView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.documentView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); + viewHolder.itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); + viewHolder.date.setVisibility(View.VISIBLE); + } + else { + viewHolder.documentView.setVisibility(View.GONE); + viewHolder.audioView.setVisibility(View.GONE); + viewHolder.webxdcView.setVisibility(View.GONE); + viewHolder.date.setVisibility(View.GONE); + } + + viewHolder.itemView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); + viewHolder.itemView.setSelected(selected.contains(dcMsg)); + + viewHolder.date.setText(DateUtils.getBriefRelativeTimeSpanString(context, dcMsg.getTimestamp())); + } + + @Override + public int getSectionCount() { + return media.getSectionCount(); + } + + @Override + public int getSectionItemCount(int section) { + return media.getSectionItemCount(section); + } + + public void toggleSelection(@NonNull DcMsg mediaRecord) { + if (!selected.remove(mediaRecord)) { + selected.add(mediaRecord); + } + notifyDataSetChanged(); + } + + public void selectAll() { + selected.clear(); + selected.addAll(media.getAll()); + notifyDataSetChanged(); + } + + public int getSelectedMediaCount() { + return selected.size(); + } + + public Set getSelectedMedia() { + return Collections.unmodifiableSet(new HashSet<>(selected)); + } + + public void clearSelection() { + selected.clear(); + notifyDataSetChanged(); + } + + interface ItemClickListener { + void onMediaClicked(@NonNull DcMsg mediaRecord); + void onMediaLongClicked(DcMsg mediaRecord); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..62407dcd00e15d6346a07c462a7141719bf799ca --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java @@ -0,0 +1,281 @@ +package org.thoughtcrime.securesms; + +import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; +import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Set; + +public class AllMediaDocumentsFragment + extends MessageSelectorFragment + implements LoaderManager.LoaderCallbacks, + AllMediaDocumentsAdapter.ItemClickListener +{ + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String VIEWTYPE1 = "viewtype1"; + public static final String VIEWTYPE2 = "viewtype2"; + + protected TextView noMedia; + protected RecyclerView recyclerView; + private StickyHeaderGridLayoutManager gridManager; + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + private int viewtype1; + private int viewtype2; + + protected int chatId; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + dcContext = DcHelper.getContext(getContext()); + chatId = getArguments().getInt(CHAT_ID_EXTRA, -1); + viewtype1 = getArguments().getInt(VIEWTYPE1, 0); + viewtype2 = getArguments().getInt(VIEWTYPE2, 0); + + getLoaderManager().initLoader(0, null, this); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.profile_documents_fragment, container, false); + + this.recyclerView = ViewUtil.findById(view, R.id.recycler_view); + this.noMedia = ViewUtil.findById(view, R.id.no_documents); + this.gridManager = new StickyHeaderGridLayoutManager(1); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(recyclerView, true, false, true, true); + + this.recyclerView.setAdapter(new AllMediaDocumentsAdapter(getContext(), + new BucketedThreadMediaLoader.BucketedThreadMedia(getContext()), + this)); + this.recyclerView.setLayoutManager(gridManager); + this.recyclerView.setHasFixedSize(true); + + DcEventCenter eventCenter = DcHelper.getEventCenter(getContext()); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + return view; + } + + @Override + public void onDestroyView() { + DcHelper.getEventCenter(getContext()).removeObservers(this); + super.onDestroyView(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + getLoaderManager().restartLoader(0, null, this); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (gridManager != null) { + this.gridManager = new StickyHeaderGridLayoutManager(1); + this.recyclerView.setLayoutManager(gridManager); + } + } + + @Override + public Loader onCreateLoader(int i, Bundle bundle) { + return new BucketedThreadMediaLoader(getContext(), chatId, viewtype1, viewtype2, 0); + } + + @Override + public void onLoadFinished(Loader loader, BucketedThreadMediaLoader.BucketedThreadMedia bucketedThreadMedia) { + ((AllMediaDocumentsAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia); + ((AllMediaDocumentsAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged(); + + noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE); + if (chatId == DC_CHAT_NO_CHAT) { + if (viewtype1 == DcMsg.DC_MSG_WEBXDC) { + noMedia.setText(R.string.all_apps_empty_hint); + } else if (viewtype1 == DcMsg.DC_MSG_FILE){ + noMedia.setText(R.string.all_files_empty_hint); + } else { + noMedia.setText(R.string.tab_all_media_empty_hint); + } + } else if (viewtype1 == DcMsg.DC_MSG_AUDIO) { + noMedia.setText(R.string.tab_audio_empty_hint); + } else if (viewtype1 == DcMsg.DC_MSG_WEBXDC) { + noMedia.setText(R.string.tab_webxdc_empty_hint); + } + getActivity().invalidateOptionsMenu(); + } + + @Override + public void onLoaderReset(Loader cursorLoader) { + ((AllMediaDocumentsAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMediaLoader.BucketedThreadMedia(getContext())); + } + + @Override + public void onMediaClicked(@NonNull DcMsg mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + } else { + handleMediaPreviewClick(mediaRecord); + } + } + + private void updateActionModeBar() { + actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount())); + setCorrectMenuVisibility(actionMode.getMenu()); + } + + private void handleMediaMultiSelectClick(@NonNull DcMsg mediaRecord) { + AllMediaDocumentsAdapter adapter = getListAdapter(); + + adapter.toggleSelection(mediaRecord); + if (adapter.getSelectedMediaCount() == 0) { + actionMode.finish(); + actionMode = null; + } else { + updateActionModeBar(); + } + } + + private void handleMediaPreviewClick(@NonNull DcMsg dcMsg) { + // audio is started by the play-button + if (dcMsg.getType()==DcMsg.DC_MSG_AUDIO || dcMsg.getType()==DcMsg.DC_MSG_VOICE) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + if (dcMsg.getType() == DcMsg.DC_MSG_WEBXDC) { + WebxdcActivity.openWebxdcActivity(context, dcMsg); + } else { + DcHelper.openForViewOrShare(getActivity(), dcMsg.getId(), Intent.ACTION_VIEW); + } + } + + @Override + public void onMediaLongClicked(DcMsg mediaRecord) { + if (actionMode == null) { + ((AllMediaDocumentsAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); + + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback); + } + } + + @Override + protected void setCorrectMenuVisibility(Menu menu) { + Set messageRecords = getListAdapter().getSelectedMedia(); + + if (actionMode != null && messageRecords.size() == 0) { + actionMode.finish(); + return; + } + + boolean singleSelection = messageRecords.size() == 1; + menu.findItem(R.id.details).setVisible(singleSelection); + menu.findItem(R.id.show_in_chat).setVisible(singleSelection); + menu.findItem(R.id.share).setVisible(singleSelection); + + boolean canResend = true; + for (DcMsg messageRecord : messageRecords) { + if (!messageRecord.isOutgoing()) { + canResend = false; + break; + } + } + menu.findItem(R.id.menu_resend).setVisible(canResend); + + boolean webxdcApp = singleSelection && messageRecords.iterator().next().getType() == DcMsg.DC_MSG_WEBXDC; + menu.findItem(R.id.menu_add_to_home_screen).setVisible(webxdcApp); + } + + private AllMediaDocumentsAdapter getListAdapter() { + return (AllMediaDocumentsAdapter) recyclerView.getAdapter(); + } + + private class ActionModeCallback implements ActionMode.Callback { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.profile_context, menu); + mode.setTitle("1"); + + setCorrectMenuVisibility(menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.details) { + handleDisplayDetails(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + mode.finish(); + return true; + } else if (itemId == R.id.delete) { + handleDeleteMessages(chatId, getListAdapter().getSelectedMedia()); + mode.finish(); + return true; + } else if (itemId == R.id.share) { + handleShare(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + return true; + } else if (itemId == R.id.menu_add_to_home_screen) { + WebxdcActivity.addToHomeScreen(getActivity(), getSelectedMessageRecord(getListAdapter().getSelectedMedia()).getId()); + mode.finish(); + return true; + } else if (itemId == R.id.show_in_chat) { + handleShowInChat(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + return true; + } else if (itemId == R.id.save) { + handleSaveAttachment(getListAdapter().getSelectedMedia()); + return true; + } else if (itemId == R.id.menu_resend) { + handleResendMessage(getListAdapter().getSelectedMedia()); + return true; + } else if (itemId == R.id.menu_select_all) { + getListAdapter().selectAll(); + updateActionModeBar(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().clearSelection(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..85d94062525a3cec6bbedc654a0fe40c83515f64 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryAdapter.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcMsg; +import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; + +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +class AllMediaGalleryAdapter extends StickyHeaderGridAdapter { + + private final Context context; + private final GlideRequests glideRequests; + private final ItemClickListener itemClickListener; + private final Set selected; + + private BucketedThreadMedia media; + + private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { + final ThumbnailView imageView; + final View selectedIndicator; + + ViewHolder(View v) { + super(v); + imageView = v.findViewById(R.id.image); + selectedIndicator = v.findViewById(R.id.selected_indicator); + } + } + + private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder { + final TextView textView; + + HeaderHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.label); + } + } + + AllMediaGalleryAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + BucketedThreadMedia media, + ItemClickListener clickListener) + { + this.context = context; + this.glideRequests = glideRequests; + this.media = media; + this.itemClickListener = clickListener; + this.selected = new HashSet<>(); + } + + public void setMedia(BucketedThreadMedia media) { + this.media = media; + } + + @Override + public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { + return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.contact_selection_list_divider, parent, false)); + } + + @Override + public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) { + return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.profile_gallery_item, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) { + ((HeaderHolder)viewHolder).textView.setText(media.getName(section)); + } + + @Override + public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) { + DcMsg mediaRecord = media.get(section, offset); + ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView; + View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator; + Slide slide = MediaUtil.getSlideForMsg(context, mediaRecord); + + if (slide != null) { + thumbnailView.setImageResource(glideRequests, slide); + } + + thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + thumbnailView.setOnLongClickListener(view -> { + itemClickListener.onMediaLongClicked(mediaRecord); + return true; + }); + + selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE); + } + + @Override + public int getSectionCount() { + return media.getSectionCount(); + } + + @Override + public int getSectionItemCount(int section) { + return media.getSectionItemCount(section); + } + + public void toggleSelection(@NonNull DcMsg mediaRecord) { + if (!selected.remove(mediaRecord)) { + selected.add(mediaRecord); + } + notifyDataSetChanged(); + } + + public void selectAll() { + selected.clear(); + selected.addAll(media.getAll()); + notifyDataSetChanged(); + } + + public int getSelectedMediaCount() { + return selected.size(); + } + + public Set getSelectedMedia() { + return Collections.unmodifiableSet(new HashSet<>(selected)); + } + + public void clearSelection() { + selected.clear(); + notifyDataSetChanged(); + } + + interface ItemClickListener { + void onMediaClicked(@NonNull DcMsg mediaRecord); + void onMediaLongClicked(DcMsg mediaRecord); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryFragment.java b/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..48f8b4635cab65b19ab67ff067bf3be047ef228c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaGalleryFragment.java @@ -0,0 +1,268 @@ +package org.thoughtcrime.securesms; + +import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; +import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Set; + +public class AllMediaGalleryFragment + extends MessageSelectorFragment + implements LoaderManager.LoaderCallbacks, + AllMediaGalleryAdapter.ItemClickListener +{ + public static final String CHAT_ID_EXTRA = "chat_id"; + + protected TextView noMedia; + protected RecyclerView recyclerView; + private StickyHeaderGridLayoutManager gridManager; + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + + private int chatId; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + dcContext = DcHelper.getContext(getContext()); + chatId = getArguments().getInt(CHAT_ID_EXTRA, -1); + + getLoaderManager().initLoader(0, null, this); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.profile_gallery_fragment, container, false); + + this.recyclerView = ViewUtil.findById(view, R.id.media_grid); + this.noMedia = ViewUtil.findById(view, R.id.no_images); + this.gridManager = new StickyHeaderGridLayoutManager(getCols()); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(recyclerView, true, false, true, true); + + this.recyclerView.setAdapter(new AllMediaGalleryAdapter(getContext(), + GlideApp.with(this), + new BucketedThreadMediaLoader.BucketedThreadMedia(getContext()), + this)); + this.recyclerView.setLayoutManager(gridManager); + this.recyclerView.setHasFixedSize(true); + + DcEventCenter eventCenter = DcHelper.getEventCenter(getContext()); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + return view; + } + + @Override + public void onDestroyView() { + DcEventCenter eventCenter = DcHelper.getEventCenter(getContext()); + eventCenter.removeObservers(this); + super.onDestroyView(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + getLoaderManager().restartLoader(0, null, this); + } + + private int getCols() { + return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE? 5 : 3; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (gridManager != null) { + this.gridManager = new StickyHeaderGridLayoutManager(getCols()); + this.recyclerView.setLayoutManager(gridManager); + } + } + + @Override + public Loader onCreateLoader(int i, Bundle bundle) { + return new BucketedThreadMediaLoader(getContext(), chatId, DcMsg.DC_MSG_IMAGE, DcMsg.DC_MSG_GIF, DcMsg.DC_MSG_VIDEO); + } + + @Override + public void onLoadFinished(Loader loader, BucketedThreadMediaLoader.BucketedThreadMedia bucketedThreadMedia) { + ((AllMediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia); + ((AllMediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged(); + + noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE); + if (chatId == DC_CHAT_NO_CHAT) { + noMedia.setText(R.string.tab_all_media_empty_hint); + } + getActivity().invalidateOptionsMenu(); + } + + @Override + public void onLoaderReset(Loader cursorLoader) { + ((AllMediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMediaLoader.BucketedThreadMedia(getContext())); + } + + @Override + public void onMediaClicked(@NonNull DcMsg mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + } else { + handleMediaPreviewClick(mediaRecord); + } + } + + private void updateActionModeBar() { + actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount())); + setCorrectMenuVisibility(actionMode.getMenu()); + } + + private void handleMediaMultiSelectClick(@NonNull DcMsg mediaRecord) { + AllMediaGalleryAdapter adapter = getListAdapter(); + + adapter.toggleSelection(mediaRecord); + if (adapter.getSelectedMediaCount() == 0) { + actionMode.finish(); + actionMode = null; + } else { + updateActionModeBar(); + } + } + + private void handleMediaPreviewClick(@NonNull DcMsg mediaRecord) { + if (mediaRecord.getFile() == null) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.putExtra(MediaPreviewActivity.DC_MSG_ID, mediaRecord.getId()); + intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, Address.fromChat(chatId)); + intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, false); + intent.putExtra(MediaPreviewActivity.OPENED_FROM_PROFILE, true); + context.startActivity(intent); + } + + @Override + public void onMediaLongClicked(DcMsg mediaRecord) { + if (actionMode == null) { + ((AllMediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); + recyclerView.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback); + } + } + + @Override + protected void setCorrectMenuVisibility(Menu menu) { + Set messageRecords = getListAdapter().getSelectedMedia(); + + if (actionMode != null && messageRecords.size() == 0) { + actionMode.finish(); + return; + } + + boolean singleSelection = messageRecords.size() == 1; + menu.findItem(R.id.details).setVisible(singleSelection); + menu.findItem(R.id.show_in_chat).setVisible(singleSelection); + menu.findItem(R.id.share).setVisible(singleSelection); + + boolean canResend = true; + for (DcMsg messageRecord : messageRecords) { + if (!messageRecord.isOutgoing()) { + canResend = false; + break; + } + } + menu.findItem(R.id.menu_resend).setVisible(canResend); + } + + private AllMediaGalleryAdapter getListAdapter() { + return (AllMediaGalleryAdapter) recyclerView.getAdapter(); + } + + private class ActionModeCallback implements ActionMode.Callback { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.profile_context, menu); + mode.setTitle("1"); + + setCorrectMenuVisibility(menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.details) { + handleDisplayDetails(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + mode.finish(); + return true; + } else if (itemId == R.id.delete) { + handleDeleteMessages(chatId, getListAdapter().getSelectedMedia()); + mode.finish(); + return true; + } else if (itemId == R.id.share) { + handleShare(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + return true; + } else if (itemId == R.id.show_in_chat) { + handleShowInChat(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); + return true; + } else if (itemId == R.id.save) { + handleSaveAttachment(getListAdapter().getSelectedMedia()); + return true; + } else if (itemId == R.id.menu_resend) { + handleResendMessage(getListAdapter().getSelectedMedia()); + return true; + } else if (itemId == R.id.menu_select_all) { + getListAdapter().selectAll(); + updateActionModeBar(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().clearSelection(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java new file mode 100644 index 0000000000000000000000000000000000000000..5bd1cc7a80b89ea2a995bf65f7821e20ddace173 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.NetworkCapabilities; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.app.NotificationManagerCompat; +import androidx.multidex.MultiDexApplication; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcEventEmitter; +import com.b44t.messenger.FFITransport; + +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.connect.FetchWorker; +import org.thoughtcrime.securesms.connect.ForegroundDetector; +import org.thoughtcrime.securesms.connect.KeepAliveService; +import org.thoughtcrime.securesms.connect.NetworkStateReceiver; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.geolocation.DcLocationManager; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.notifications.InChatSounds; +import org.thoughtcrime.securesms.notifications.NotificationCenter; +import org.thoughtcrime.securesms.util.AndroidSignalProtocolLogger; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.SignalProtocolLoggerProvider; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.webxdc.WebxdcGarbageCollectionWorker; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.TimeUnit; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class ApplicationContext extends MultiDexApplication { + private static final String TAG = ApplicationContext.class.getSimpleName(); + + public static DcAccounts dcAccounts; + public Rpc rpc; + public DcContext dcContext; + public DcLocationManager dcLocationManager; + public DcEventCenter eventCenter; + public NotificationCenter notificationCenter; + private JobManager jobManager; + + private int debugOnAvailableCount; + private int debugOnBlockedStatusChangedCount; + private int debugOnCapabilitiesChangedCount; + private int debugOnLinkPropertiesChangedCount; + + public static ApplicationContext getInstance(@NonNull Context context) { + return (ApplicationContext)context.getApplicationContext(); + } + + @Override + public void onCreate() { + super.onCreate(); + + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + StringWriter stringWriter = new StringWriter(); + throwable.printStackTrace(new PrintWriter(stringWriter, true)); + String errorMsg = "Android " + Build.VERSION.RELEASE +":\n" + stringWriter.getBuffer().toString(); + String subject = "ArcaneChat " + BuildConfig.VERSION_NAME + " Crash Report"; + Intent intent = new Intent(android.content.Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + intent.putExtra(android.content.Intent.EXTRA_TEXT, subject + "\n\n" + errorMsg); + intent.putExtra(Intent.EXTRA_EMAIL, "adb@merlinux.eu"); + Intent chooser = Intent.createChooser(intent, subject); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + startActivity(chooser); + + try { + ApplicationContext.this.finalize(); + } catch (Throwable e) {} + }); + + // if (LeakCanary.isInAnalyzerProcess(this)) { + // // This process is dedicated to LeakCanary for heap analysis. + // // You should not init your app in this process. + // return; + // } + // LeakCanary.install(this); + + Log.i("DeltaChat", "++++++++++++++++++ ApplicationContext.onCreate() ++++++++++++++++++"); + + System.loadLibrary("native-utils"); + + dcAccounts = new DcAccounts(new File(getFilesDir(), "accounts").getAbsolutePath()); + Log.i(TAG, "DcAccounts created"); + rpc = new Rpc(new FFITransport(dcAccounts.getJsonrpcInstance())); + Log.i(TAG, "Rpc created"); + AccountManager.getInstance().migrateToDcAccounts(this); + + // October-2025 migration: delete deprecated "permanent channel" id + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + notificationManager.deleteNotificationChannel("dc_foreground_notification_ch"); + // end October-2025 migration + + int[] allAccounts = dcAccounts.getAll(); + Log.i(TAG, "Number of profiles: " + allAccounts.length); + for (int accountId : allAccounts) { + DcContext ac = dcAccounts.getAccount(accountId); + if (!ac.isOpen()) { + try { + DatabaseSecret secret = DatabaseSecretProvider.getOrCreateDatabaseSecret(this, accountId); + boolean res = ac.open(secret.asString()); + if (res) Log.i(TAG, "Successfully opened account " + accountId + ", path: " + ac.getBlobdir()); + else Log.e(TAG, "Error opening account " + accountId + ", path: " + ac.getBlobdir()); + } catch (Exception e) { + Log.e(TAG, "Failed to open account " + accountId + ", path: " + ac.getBlobdir() + ": " + e); + e.printStackTrace(); + } + } + + // 2025.11.12: this is needed until core starts ignoring "delete_server_after" for chatmail + if (ac.isChatmail()) { + ac.setConfig("delete_server_after", null); // reset + } + } + if (allAccounts.length == 0) { + try { + rpc.addAccount(); + } catch (RpcException e) { + e.printStackTrace(); + } + } + dcContext = dcAccounts.getSelectedAccount(); + notificationCenter = new NotificationCenter(this); + eventCenter = new DcEventCenter(this); + new Thread(() -> { + Log.i(TAG, "Starting event loop"); + DcEventEmitter emitter = dcAccounts.getEventEmitter(); + Log.i(TAG, "DcEventEmitter obtained"); + while (true) { + DcEvent event = emitter.getNextEvent(); + if (event==null) { + break; + } + eventCenter.handleEvent(event); + } + Log.i("DeltaChat", "shutting down event handler"); + }, "eventThread").start(); + + // set translations before starting I/O to avoid sending untranslated MDNs (issue #2288) + DcHelper.setStockTranslations(this); + + dcAccounts.startIo(); + + new ForegroundDetector(ApplicationContext.getInstance(this)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ConnectivityManager connectivityManager = + (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE); + connectivityManager.registerDefaultNetworkCallback(new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull android.net.Network network) { + Log.i("DeltaChat", "++++++++++++++++++ NetworkCallback.onAvailable() #" + debugOnAvailableCount++); + dcAccounts.maybeNetwork(); + } + + @Override + public void onBlockedStatusChanged(@NonNull android.net.Network network, boolean blocked) { + Log.i("DeltaChat", "++++++++++++++++++ NetworkCallback.onBlockedStatusChanged() #" + debugOnBlockedStatusChangedCount++); + } + + @Override + public void onCapabilitiesChanged(@NonNull android.net.Network network, NetworkCapabilities networkCapabilities) { + // usually called after onAvailable(), so a maybeNetwork seems contraproductive + Log.i("DeltaChat", "++++++++++++++++++ NetworkCallback.onCapabilitiesChanged() #" + debugOnCapabilitiesChangedCount++); + } + + @Override + public void onLinkPropertiesChanged(@NonNull android.net.Network network, LinkProperties linkProperties) { + Log.i("DeltaChat", "++++++++++++++++++ NetworkCallback.onLinkPropertiesChanged() #" + debugOnLinkPropertiesChangedCount++); + } + }); + } // no else: use old method for debugging + BroadcastReceiver networkStateReceiver = new NetworkStateReceiver(); + registerReceiver(networkStateReceiver, new IntentFilter(android.net.ConnectivityManager.CONNECTIVITY_ACTION)); + + KeepAliveService.maybeStartSelf(this); + + initializeLogging(); + initializeJobManager(); + InChatSounds.getInstance(this); + + dcLocationManager = new DcLocationManager(this); + DynamicTheme.setDefaultDayNightMode(this); + + IntentFilter filter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); + registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Util.localeChanged(); + DcHelper.setStockTranslations(context); + } + }, filter); + + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + + if (Prefs.isPushEnabled(this)) { + FcmReceiveService.register(this); + } else { + Log.i(TAG, "FCM disabled at build time"); + // MAYBE TODO: i think the ApplicationContext is also created + // when the app is stated by FetchWorker timeouts. + // in this case, the normal threads shall not be started. + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + PeriodicWorkRequest fetchWorkRequest = new PeriodicWorkRequest.Builder( + FetchWorker.class, + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, // usually 15 minutes + TimeUnit.MILLISECONDS, + PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, // the start may be preferred by up to 5 minutes, so we run every 10-15 minutes + TimeUnit.MILLISECONDS) + .setConstraints(constraints) + .build(); + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "FetchWorker", + ExistingPeriodicWorkPolicy.KEEP, + fetchWorkRequest); + } + + PeriodicWorkRequest webxdcGarbageCollectionRequest = new PeriodicWorkRequest.Builder( + WebxdcGarbageCollectionWorker.class, + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, + TimeUnit.MILLISECONDS, + PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, + TimeUnit.MILLISECONDS) + .build(); + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "WebxdcGarbageCollectionWorker", + ExistingPeriodicWorkPolicy.KEEP, + webxdcGarbageCollectionRequest); + } + + public JobManager getJobManager() { + return jobManager; + } + + private void initializeLogging() { + SignalProtocolLoggerProvider.setProvider(new AndroidSignalProtocolLogger()); + } + + private void initializeJobManager() { + this.jobManager = new JobManager(this, 5); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..75249e8252c4f0d24ed0879d2fb1cf2996d1593e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.NotificationManagerCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.preference.Preference; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment; +import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment; +import org.thoughtcrime.securesms.preferences.ChatsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.PrivacyPreferenceFragment; +import org.thoughtcrime.securesms.preferences.CorrectedPreferenceFragment; +import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.widgets.ProfilePreference; +import org.thoughtcrime.securesms.qr.BackupTransferActivity; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ScreenLockUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * The Activity for application preference display and management. + * + * @author Moxie Marlinspike + * + */ + +public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarActivity + implements SharedPreferences.OnSharedPreferenceChangeListener +{ + private static final String PREFERENCE_CATEGORY_PROFILE = "preference_category_profile"; + private static final String PREFERENCE_CATEGORY_NOTIFICATIONS = "preference_category_notifications"; + private static final String PREFERENCE_CATEGORY_APPEARANCE = "preference_category_appearance"; + private static final String PREFERENCE_CATEGORY_CHATS = "preference_category_chats"; + private static final String PREFERENCE_CATEGORY_PRIVACY = "preference_category_privacy"; + private static final String PREFERENCE_CATEGORY_MULTIDEVICE = "preference_category_multidevice"; + private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced"; + private static final String PREFERENCE_CATEGORY_CONNECTIVITY = "preference_category_connectivity"; + private static final String PREFERENCE_CATEGORY_DONATE = "preference_category_donate"; + private static final String PREFERENCE_CATEGORY_HELP = "preference_category_help"; + + public static final int REQUEST_CODE_SET_BACKGROUND = 11; + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + setContentView(R.layout.activity_application_preferences); + + //noinspection ConstantConditions + this.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.fragment)); + + if (icicle == null) { + initFragment(R.id.fragment, new ApplicationPreferenceFragment()); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) + { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK && requestCode == ScreenLockUtil.REQUEST_CODE_CONFIRM_CREDENTIALS) { + showBackupProvider(); + return; + } + Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment); + fragment.onActivityResult(requestCode, resultCode, data); + } + + @Override + public boolean onSupportNavigateUp() { + FragmentManager fragmentManager = getSupportFragmentManager(); + if (fragmentManager.getBackStackEntryCount() > 0) { + fragmentManager.popBackStack(); + } else { + Intent intent = new Intent(this, ConversationListActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + finish(); + } + return true; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(Prefs.THEME_PREF)) { + DynamicTheme.setDefaultDayNightMode(this); + recreate(); + } + } + + public void showBackupProvider() { + Intent intent = new Intent(this, BackupTransferActivity.class); + intent.putExtra(BackupTransferActivity.TRANSFER_MODE, BackupTransferActivity.TransferMode.SENDER_SHOW_QR.getInt()); + startActivity(intent); + overridePendingTransition(0, 0); // let the activity appear in the same way as the other pages (which are mostly fragments) + finishAffinity(); // see comment (**2) in BackupTransferActivity.doFinish() + } + + public static class ApplicationPreferenceFragment extends CorrectedPreferenceFragment implements DcEventCenter.DcEventDelegate { + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + this.findPreference(PREFERENCE_CATEGORY_PROFILE) + .setOnPreferenceClickListener(new ProfileClickListener()); + this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_NOTIFICATIONS)); + this.findPreference(PREFERENCE_CATEGORY_CONNECTIVITY) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_CONNECTIVITY)); + this.findPreference(PREFERENCE_CATEGORY_APPEARANCE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_APPEARANCE)); + this.findPreference(PREFERENCE_CATEGORY_CHATS) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_CHATS)); + this.findPreference(PREFERENCE_CATEGORY_PRIVACY) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_PRIVACY)); + this.findPreference(PREFERENCE_CATEGORY_MULTIDEVICE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_MULTIDEVICE)); + this.findPreference(PREFERENCE_CATEGORY_ADVANCED) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_ADVANCED)); + + this.findPreference(PREFERENCE_CATEGORY_DONATE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DONATE)); + + this.findPreference(PREFERENCE_CATEGORY_HELP) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_HELP)); + + DcHelper.getEventCenter(getActivity()).addObserver(DcContext.DC_EVENT_CONNECTIVITY_CHANGED, this); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences); + } + + @Override + public void onResume() { + super.onResume(); + //noinspection ConstantConditions + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.menu_settings); + setCategorySummaries(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(getActivity()).removeObservers(this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_CONNECTIVITY_CHANGED) { + this.findPreference(PREFERENCE_CATEGORY_CONNECTIVITY) + .setSummary(DcHelper.getConnectivitySummary(getActivity(), getString(R.string.connectivity_connected))); + } + } + + private void setCategorySummaries() { + ((ProfilePreference)this.findPreference(PREFERENCE_CATEGORY_PROFILE)).refresh(); + + this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS) + .setSummary(NotificationsPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_APPEARANCE) + .setSummary(AppearancePreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_CHATS) + .setSummary(ChatsPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_PRIVACY) + .setSummary(PrivacyPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_CONNECTIVITY) + .setSummary(DcHelper.getConnectivitySummary(getActivity(), getString(R.string.connectivity_connected))); + this.findPreference(PREFERENCE_CATEGORY_HELP) + .setSummary(AdvancedPreferenceFragment.getVersion(getActivity())); + } + + private class CategoryClickListener implements Preference.OnPreferenceClickListener { + private final String category; + + CategoryClickListener(String category) { + this.category = category; + } + + @Override + public boolean onPreferenceClick(Preference preference) { + Fragment fragment = null; + + switch (category) { + case PREFERENCE_CATEGORY_NOTIFICATIONS: + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(getActivity()); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || notificationManager.areNotificationsEnabled()) { + fragment = new NotificationsPreferenceFragment(); + } else { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.notifications_disabled) + .setMessage(R.string.perm_explain_access_to_notifications_denied) + .setPositiveButton(R.string.perm_continue, (dialog, which) -> getActivity().startActivity(Permissions.getApplicationSettingsIntent(getActivity()))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + break; + case PREFERENCE_CATEGORY_CONNECTIVITY: + startActivity(new Intent(getActivity(), ConnectivityActivity.class)); + break; + case PREFERENCE_CATEGORY_APPEARANCE: + fragment = new AppearancePreferenceFragment(); + break; + case PREFERENCE_CATEGORY_CHATS: + fragment = new ChatsPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_PRIVACY: + fragment = new PrivacyPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_MULTIDEVICE: + if (!ScreenLockUtil.applyScreenLock(getActivity(), getString(R.string.multidevice_title), + getString(R.string.multidevice_this_creates_a_qr_code) + "\n\n" + getString(R.string.enter_system_secret_to_continue), + ScreenLockUtil.REQUEST_CODE_CONFIRM_CREDENTIALS)) { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.multidevice_title) + .setMessage(R.string.multidevice_this_creates_a_qr_code) + .setPositiveButton(R.string.perm_continue, + (dialog, which) -> ((ApplicationPreferencesActivity)getActivity()).showBackupProvider()) + .setNegativeButton(R.string.cancel, null) + .show(); + ; + } + break; + case PREFERENCE_CATEGORY_ADVANCED: + fragment = new AdvancedPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_DONATE: + IntentUtils.showInBrowser(requireActivity(), "https://arcanechat.me/#contribute"); + break; + case PREFERENCE_CATEGORY_HELP: + startActivity(new Intent(getActivity(), LocalHelpActivity.class)); + break; + default: + throw new AssertionError(); + } + + if (fragment != null) { + Bundle args = new Bundle(); + fragment.setArguments(args); + + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.replace(R.id.fragment, fragment); + fragmentTransaction.addToBackStack(null); + fragmentTransaction.commit(); + } + + return true; + } + } + + private class ProfileClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(preference.getContext(), CreateProfileActivity.class); + getActivity().startActivity(intent); + return true; + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/AttachContactActivity.java b/src/main/java/org/thoughtcrime/securesms/AttachContactActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..4747254ea9b38555cfd2ed84aa63b9288812be25 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/AttachContactActivity.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms; + +import android.content.Intent; + +import org.thoughtcrime.securesms.connect.DcHelper; + +public class AttachContactActivity extends ContactSelectionActivity { + + public static final String CONTACT_ID_EXTRA = "contact_id_extra"; + + @Override + public void onContactSelected(int contactId) { + Intent intent = new Intent(); + intent.putExtra(CONTACT_ID_EXTRA, contactId); + setResult(RESULT_OK, intent); + finish(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..b75d76a422067df72fc2dc3f8c01540d0a285311 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms; + +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.ViewConfiguration; +import android.view.WindowManager; + +import androidx.activity.EdgeToEdge; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.WindowCompat; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.reflect.Field; + + +public abstract class BaseActionBarActivity extends AppCompatActivity { + + private static final String TAG = BaseActionBarActivity.class.getSimpleName(); + protected DynamicTheme dynamicTheme = new DynamicTheme(); + + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + onPreCreate(); + super.onCreate(savedInstanceState); + + // Only enable Edge-to-Edge if it is well supported + if (ViewUtil.isEdgeToEdgeSupported()) { + // docs says to use: WindowCompat.enableEdgeToEdge(getWindow()); + // but it actually makes things worse, the next takes care of setting the 3-buttons navigation bar background + EdgeToEdge.enable(this); + + // force white text in status bar so it visible over background color + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()).setAppearanceLightStatusBars(false); + } + } + + @Override + protected void onPostCreate(@Nullable Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // Apply adjustments to the toolbar for edge-to-edge display + ViewUtil.adjustToolbarForE2E(this); + } + + @Override + protected void onResume() { + super.onResume(); + initializeScreenshotSecurity(); + dynamicTheme.onResume(this); + } + + private void initializeScreenshotSecurity() { + if (Prefs.isScreenSecurityEnabled(this)) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + } + + /** + * Modified from: http://stackoverflow.com/a/13098824 + */ + private void forceOverflowMenu() { + try { + ViewConfiguration config = ViewConfiguration.get(this); + Field menuKeyField = ViewConfiguration.class.getDeclaredField("sHasPermanentMenuKey"); + if(menuKeyField != null) { + menuKeyField.setAccessible(true); + menuKeyField.setBoolean(config, false); + } + } catch (IllegalAccessException e) { + Log.w(TAG, "Failed to force overflow menu."); + } catch (NoSuchFieldException e) { + Log.w(TAG, "Failed to force overflow menu."); + } + } + + public void makeSearchMenuVisible(final Menu menu, final MenuItem searchItem, boolean visible) { + for (int i = 0; i < menu.size(); ++i) { + MenuItem item = menu.getItem(i); + int id = item.getItemId(); + if (id == R.id.menu_search_up || id == R.id.menu_search_down) { + item.setVisible(visible); + } else if (id == R.id.menu_search_counter) { + item.setVisible(false); // always hide menu_search_counter initially + } else if (item == searchItem) { + ; // searchItem is just always visible + } else { + item.setVisible(!visible); // if search is shown, other items are hidden - and the other way round + } + } + } + + protected T initFragment(@IdRes int target, + @NonNull T fragment) + { + return initFragment(target, fragment, null); + } + + protected T initFragment(@IdRes int target, + @NonNull T fragment, + @Nullable Bundle extras) + { + Bundle args = new Bundle(); + + if (extras != null) { + args.putAll(extras); + } + + fragment.setArguments(args); + getSupportFragmentManager().beginTransaction() + .replace(target, fragment) + .commitAllowingStateLoss(); + return fragment; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java new file mode 100644 index 0000000000000000000000000000000000000000..4b2072c1d058b5a3bd79cd13e1acf47df446941b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.HashSet; +import java.util.Set; + +import chat.delta.rpc.Rpc; + +public abstract class BaseConversationItem extends LinearLayout + implements BindableConversationItem +{ + static final long PULSE_HIGHLIGHT_MILLIS = 500; + + protected DcMsg messageRecord; + protected DcChat dcChat; + protected TextView bodyText; + + protected final Context context; + protected final DcContext dcContext; + protected final Rpc rpc; + protected Recipient conversationRecipient; + + protected @NonNull Set batchSelected = new HashSet<>(); + + protected final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + + public BaseConversationItem(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + this.dcContext = DcHelper.getContext(context); + this.rpc = DcHelper.getRpc(context); + } + + protected void bind(@NonNull DcMsg messageRecord, + @NonNull DcChat dcChat, + @NonNull Set batchSelected, + boolean pulseHighlight, + @NonNull Recipient conversationRecipient) + { + this.messageRecord = messageRecord; + this.dcChat = dcChat; + this.batchSelected = batchSelected; + this.conversationRecipient = conversationRecipient; + setInteractionState(messageRecord, pulseHighlight); + } + + protected void setInteractionState(DcMsg messageRecord, boolean pulseHighlight) { + final int[] attributes = new int[] { + R.attr.conversation_item_background, + R.attr.conversation_item_background_animated, + }; + + if (batchSelected.contains(messageRecord)) { + final TypedArray attrs = context.obtainStyledAttributes(attributes); + ViewUtil.setBackground(this, attrs.getDrawable(0)); + attrs.recycle(); + setSelected(true); + } else if (pulseHighlight) { + final TypedArray attrs = context.obtainStyledAttributes(attributes); + ViewUtil.setBackground(this, attrs.getDrawable(1)); + attrs.recycle(); + setSelected(true); + postDelayed(() -> setSelected(false), PULSE_HIGHLIGHT_MILLIS); + } else { + setSelected(false); + } + } + + @Override + public void setOnClickListener(OnClickListener l) { + super.setOnClickListener(new ClickListener(l)); + } + + protected boolean shouldInterceptClicks(DcMsg messageRecord) { + return batchSelected.isEmpty() + && (messageRecord.isFailed() + || messageRecord.getInfoType() == DcMsg.DC_INFO_CHAT_E2EE + || messageRecord.getInfoType() == DcMsg.DC_INFO_PROTECTION_ENABLED + || messageRecord.getInfoType() == DcMsg.DC_INFO_INVALID_UNENCRYPTED_MAIL); + } + + protected void onAccessibilityClick() {} + + protected class PassthroughClickListener implements View.OnLongClickListener, View.OnClickListener { + + @Override + public boolean onLongClick(View v) { + if (bodyText.hasSelection()) { + return false; + } + performLongClick(); + return true; + } + + @Override + public void onClick(View v) { + performClick(); + } + } + + protected class ClickListener implements View.OnClickListener { + private final OnClickListener parent; + + ClickListener(@Nullable OnClickListener parent) { + this.parent = parent; + } + + public void onClick(View v) { + if (!shouldInterceptClicks(messageRecord) && parent != null) { + if (batchSelected.isEmpty() && Util.isTouchExplorationEnabled(context)) { + BaseConversationItem.this.onAccessibilityClick(); + } + parent.onClick(v); + } else if (messageRecord.isFailed()) { + View view = View.inflate(context, R.layout.message_details_view, null); + TextView detailsText = view.findViewById(R.id.details_text); + detailsText.setText(messageRecord.getError()); + + AlertDialog d = new AlertDialog.Builder(context) + .setView(view) + .setTitle(R.string.error) + .setPositiveButton(R.string.ok, null) + .create(); + d.show(); + } else if (messageRecord.getInfoType() == DcMsg.DC_INFO_CHAT_E2EE || messageRecord.getInfoType() == DcMsg.DC_INFO_PROTECTION_ENABLED) { + DcHelper.showProtectionEnabledDialog(context); + } else if (messageRecord.getInfoType() == DcMsg.DC_INFO_INVALID_UNENCRYPTED_MAIL) { + DcHelper.showInvalidUnencryptedDialog(context); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListAdapter.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..3114c3524e7d37bd84847eb057058082bccd3131 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListAdapter.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms; + +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public abstract class BaseConversationListAdapter extends RecyclerView.Adapter { + protected final Set batchSet = Collections.synchronizedSet(new HashSet()); + protected boolean batchMode = false; + + public abstract void selectAllThreads(); + + public void initializeBatchMode(boolean toggle) { + batchMode = toggle; + batchSet.clear(); + notifyDataSetChanged(); + } + + public void toggleThreadInBatchSet(long threadId) { + if (batchSet.contains(threadId)) { + batchSet.remove(threadId); + } else if (threadId != -1) { + batchSet.add(threadId); + } + } + + public Set getBatchSelections() { + return batchSet; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..9d74fc122e945dec35e27ee556dd97e2017a7ee3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java @@ -0,0 +1,456 @@ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.util.ShareUtil.acquireRelayMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedText; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedUris; +import static org.thoughtcrime.securesms.util.ShareUtil.isForwarding; +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.AsyncTask; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.core.content.ContextCompat; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.fragment.app.Fragment; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.google.android.material.snackbar.Snackbar; + +import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.connect.DirectShareUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.SendRelayedMessageUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +public abstract class BaseConversationListFragment extends Fragment implements ActionMode.Callback { + protected ActionMode actionMode; + protected PulsingFloatingActionButton fab; + + protected abstract boolean offerToArchive(); + protected abstract void setFabVisibility(boolean isActionMode); + protected abstract BaseConversationListAdapter getListAdapter(); + + protected void onItemClick(long chatId) { + if (actionMode == null) { + ((ConversationSelectedListener) requireActivity()).onCreateConversation((int)chatId); + } else { + BaseConversationListAdapter adapter = getListAdapter(); + adapter.toggleThreadInBatchSet(chatId); + + if (adapter.getBatchSelections().isEmpty()) { + actionMode.finish(); + } else { + updateActionModeItems(actionMode.getMenu()); + actionMode.setTitle(String.valueOf(adapter.getBatchSelections().size())); + } + + adapter.notifyDataSetChanged(); + } + } + public void onItemLongClick(long chatId) { + actionMode = ((AppCompatActivity)requireActivity()).startSupportActionMode(this); + + if (actionMode != null) { + getListAdapter().initializeBatchMode(true); + getListAdapter().toggleThreadInBatchSet(chatId); + getListAdapter().notifyDataSetChanged(); + Menu menu = actionMode.getMenu(); + if (menu != null) { + updateActionModeItems(menu); + } + } + } + + protected void initializeFabClickListener(boolean isActionMode) { + Intent intent = new Intent(getActivity(), NewConversationActivity.class); + if (isRelayingMessageContent(getActivity())) { + if (isActionMode) { + fab.setOnClickListener(v -> { + final Set selectedChats = getListAdapter().getBatchSelections(); + ArrayList uris = getSharedUris(getActivity()); + String message; + if (isForwarding(getActivity())) { + message = String.format(Util.getLocale(), getString(R.string.ask_forward_multiple), selectedChats.size()); + } else if (!uris.isEmpty()) { + message = String.format(Util.getLocale(), getString(R.string.ask_send_files_to_selected_chats), uris.size(), selectedChats.size()); + } else { + message = String.format(Util.getLocale(), getString(R.string.share_text_multiple_chats), selectedChats.size(), getSharedText(getActivity())); + } + + Context context = getContext(); + if (context != null) { + if (SendRelayedMessageUtil.containsVideoType(context, uris)) { + message += "\n\n" + getString(R.string.videos_sent_without_recoding); + } + new AlertDialog.Builder(context) + .setMessage(message) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel, ((dialog, which) -> {})) + .setPositiveButton(R.string.menu_send, (dialog, which) -> { + SendRelayedMessageUtil.immediatelyRelay(getActivity(), selectedChats.toArray(new Long[selectedChats.size()])); + actionMode.finish(); + actionMode = null; + getActivity().finish(); + }) + .show(); + } + }); + } else { + acquireRelayMessageContent(getActivity(), intent); + fab.setOnClickListener(v -> requireActivity().startActivity(intent)); + } + } else { + fab.setOnClickListener(v -> startActivity(intent)); + } + } + + private boolean areSomeSelectedChatsUnpinned() { + DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedChats = getListAdapter().getBatchSelections(); + for (long chatId : selectedChats) { + DcChat dcChat = dcContext.getChat((int)chatId); + if (dcChat.getVisibility()!=DcChat.DC_CHAT_VISIBILITY_PINNED) { + return true; + } + } + return false; + } + + private boolean areSomeSelectedChatsUnmuted() { + DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedChats = getListAdapter().getBatchSelections(); + for (long chatId : selectedChats) { + DcChat dcChat = dcContext.getChat((int)chatId); + if (!dcChat.isMuted()) { + return true; + } + } + return false; + } + + private void handlePinAllSelected() { + final DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedConversations = new HashSet(getListAdapter().getBatchSelections()); + boolean doPin = areSomeSelectedChatsUnpinned(); + for (long chatId : selectedConversations) { + dcContext.setChatVisibility((int)chatId, + doPin? DcChat.DC_CHAT_VISIBILITY_PINNED : DcChat.DC_CHAT_VISIBILITY_NORMAL); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + + private void handleMuteAllSelected() { + final DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedConversations = new HashSet(getListAdapter().getBatchSelections()); + if (areSomeSelectedChatsUnmuted()) { + MuteDialog.show(getActivity(), duration -> { + for (long chatId : selectedConversations) { + dcContext.setChatMuteDuration((int)chatId, duration); + } + + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + }); + } else { + // unmute + for (long chatId : selectedConversations) { + dcContext.setChatMuteDuration((int)chatId, 0); + } + + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + } + + private void handleMarknoticedSelected() { + final DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedConversations = new HashSet(getListAdapter().getBatchSelections()); + for (long chatId : selectedConversations) { + dcContext.marknoticedChat((int)chatId); + } + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + + @SuppressLint("StaticFieldLeak") + private void handleArchiveAllSelected() { + final DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedConversations = new HashSet(getListAdapter().getBatchSelections()); + final boolean archive = offerToArchive(); + + int snackBarTitleId; + + if (archive) snackBarTitleId = R.plurals.chat_archived; + else snackBarTitleId = R.plurals.chat_unarchived; + + int count = selectedConversations.size(); + String snackBarTitle = getResources().getQuantityString(snackBarTitleId, count, count); + + new SnackbarAsyncTask(getView(), snackBarTitle, + getString(R.string.undo), + Snackbar.LENGTH_LONG, true) + { + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + + @Override + protected void executeAction(@Nullable Void parameter) { + for (long chatId : selectedConversations) { + dcContext.setChatVisibility((int)chatId, + archive? DcChat.DC_CHAT_VISIBILITY_ARCHIVED : DcChat.DC_CHAT_VISIBILITY_NORMAL); + } + } + + @Override + protected void reverseAction(@Nullable Void parameter) { + for (long threadId : selectedConversations) { + dcContext.setChatVisibility((int)threadId, + archive? DcChat.DC_CHAT_VISIBILITY_NORMAL : DcChat.DC_CHAT_VISIBILITY_ARCHIVED); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @SuppressLint("StaticFieldLeak") + private void handleDeleteAllSelected() { + final Activity activity = requireActivity(); + final DcContext dcContext = DcHelper.getContext(activity); + final Set selectedChats = getListAdapter().getBatchSelections(); + + final int chatsCount = selectedChats.size(); + final String alertText; + if (chatsCount == 1) { + long chatId = selectedChats.iterator().next(); + alertText = activity.getResources().getString(R.string.ask_delete_named_chat, dcContext.getChat((int)chatId).getName()); + } else { + alertText = activity.getResources().getQuantityString(R.plurals.ask_delete_chat, chatsCount, chatsCount); + } + + AlertDialog.Builder alert = new AlertDialog.Builder(activity); + alert.setMessage(alertText); + alert.setCancelable(true); + + alert.setPositiveButton(R.string.delete, (dialog, which) -> { + + if (!selectedChats.isEmpty()) { + new AsyncTask() { + private ProgressDialog dialog; + + @Override + protected void onPreExecute() { + dialog = ProgressDialog.show(getActivity(), + "", + requireActivity().getString(R.string.one_moment), + true, false); + } + + @Override + protected Void doInBackground(Void... params) { + int accountId = dcContext.getAccountId(); + for (long chatId : selectedChats) { + DcHelper.getNotificationCenter(requireContext()).removeNotifications(accountId, (int) chatId); + dcContext.deleteChat((int) chatId); + DirectShareUtil.clearShortcut(requireContext(), (int) chatId); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + dialog.dismiss(); + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + + alert.setNegativeButton(android.R.string.cancel, null); + AlertDialog dialog = alert.show(); + Util.redPositiveButton(dialog); + } + + private void handleSelectAllThreads() { + getListAdapter().selectAllThreads(); + actionMode.setTitle(String.valueOf(getListAdapter().getBatchSelections().size())); + updateActionModeItems(actionMode.getMenu()); + } + + private void handleAddToHomeScreen() { + final Activity activity = requireActivity(); + final DcContext dcContext = DcHelper.getContext(activity); + final Set selectedChats = getListAdapter().getBatchSelections(); + final DcChat chat = dcContext.getChat(selectedChats.iterator().next().intValue()); + + Intent intent = new Intent(activity, ShareActivity.class); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(ShareActivity.EXTRA_ACC_ID, dcContext.getAccountId()); + intent.putExtra(ShareActivity.EXTRA_CHAT_ID, chat.getId()); + + Recipient recipient = new Recipient(activity, chat); + Util.runOnAnyBackgroundThread(() -> { + Bitmap avatar = DirectShareUtil.getIconForShortcut(activity, recipient); + ShortcutInfoCompat shortcutInfoCompat = new ShortcutInfoCompat.Builder(activity, "chat-" + dcContext.getAccountId() + "-" + chat.getId()) + .setShortLabel(chat.getName()) + .setIcon(IconCompat.createWithAdaptiveBitmap(avatar)) + .setIntent(intent) + .build(); + Util.runOnMain(() -> { + if (!ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null)) { + Toast.makeText(activity, "ErrAddToHomescreen: requestPinShortcut() failed", Toast.LENGTH_LONG).show(); + } else if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + }); + }); + } + + private void updateActionModeItems(Menu menu) { + // We do not show action mode icons when relaying (= sharing or forwarding). + if (!isRelayingMessageContent(requireActivity())) { + final int selectedCount = getListAdapter().getBatchSelections().size(); + menu.findItem(R.id.menu_add_to_home_screen).setVisible(selectedCount == 1); + MenuItem archiveItem = menu.findItem(R.id.menu_archive_selected); + if (offerToArchive()) { + archiveItem.setIcon(R.drawable.ic_archive_white_24dp); + archiveItem.setTitle(R.string.menu_archive_chat); + } else { + archiveItem.setIcon(R.drawable.ic_unarchive_white_24dp); + archiveItem.setTitle(R.string.menu_unarchive_chat); + } + MenuItem pinItem = menu.findItem(R.id.menu_pin_selected); + if (areSomeSelectedChatsUnpinned()) { + pinItem.setIcon(R.drawable.ic_pin_white); + pinItem.setTitle(R.string.pin_chat); + } else { + pinItem.setIcon(R.drawable.ic_unpin_white); + pinItem.setTitle(R.string.unpin_chat); + } + MenuItem muteItem = menu.findItem(R.id.menu_mute_selected); + if (areSomeSelectedChatsUnmuted()) { + muteItem.setTitle(R.string.menu_mute); + } else { + muteItem.setTitle(R.string.menu_unmute); + } + } + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + if (isRelayingMessageContent(getActivity())) { + if (ShareUtil.getSharedContactId(getActivity()) != 0) { + return false; // no sharing of a contact to multiple recipients at the same time, we can reconsider when that becomes a real-world need + } + Context context = getContext(); + if (context != null) { + fab.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_send_sms_white_24dp)); + } + setFabVisibility(true); + initializeFabClickListener(true); + } else { + + MenuInflater inflater = requireActivity().getMenuInflater(); + inflater.inflate(R.menu.conversation_list, menu); + } + + mode.setTitle("1"); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_select_all) { + handleSelectAllThreads(); + return true; + } else if (itemId == R.id.menu_delete_selected) { + handleDeleteAllSelected(); + return true; + } else if (itemId == R.id.menu_pin_selected) { + handlePinAllSelected(); + return true; + } else if (itemId == R.id.menu_archive_selected) { + handleArchiveAllSelected(); + return true; + } else if (itemId == R.id.menu_mute_selected) { + handleMuteAllSelected(); + return true; + } else if (itemId == R.id.menu_marknoticed_selected) { + handleMarknoticedSelected(); + return true; + } else if (itemId == R.id.menu_add_to_home_screen) { + handleAddToHomeScreen(); + return true; + } + + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().initializeBatchMode(false); + + Context context = getContext(); + if (context != null) { + fab.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_add_white_24dp)); + } + setFabVisibility(false); + initializeFabClickListener(false); + } + + public interface ConversationSelectedListener { + void onCreateConversation(int chatId); + void onSwitchToArchive(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java new file mode 100644 index 0000000000000000000000000000000000000000..af0740195603066f39a2129be3ce9838bbbae30e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Set; + +public interface BindableConversationItem extends Unbindable { + void bind(@NonNull DcMsg messageRecord, + @NonNull DcChat dcChat, + @NonNull GlideRequests glideRequests, + @NonNull Set batchSelected, + @NonNull Recipient recipients, + boolean pulseHighlight); + + DcMsg getMessageRecord(); + + void setEventListener(@Nullable EventListener listener); + + interface EventListener { + void onQuoteClicked(DcMsg messageRecord); + void onJumpToOriginalClicked(DcMsg messageRecord); + void onShowFullClicked(DcMsg messageRecord); + void onDownloadClicked(DcMsg messageRecord); + void onReactionClicked(DcMsg messageRecord); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java new file mode 100644 index 0000000000000000000000000000000000000000..188a38797b6000467f55fd42b61ed15688e4aa16 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcLot; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.Set; + +public interface BindableConversationListItem extends Unbindable { + + public void bind(@NonNull ThreadRecord thread, + int msgId, + @NonNull DcLot dcSummary, + @NonNull GlideRequests glideRequests, + @NonNull Set selectedThreads, boolean batchMode); +} diff --git a/src/main/java/org/thoughtcrime/securesms/BlockedContactsActivity.java b/src/main/java/org/thoughtcrime/securesms/BlockedContactsActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..e5ca41b4f1a7572fe44854ff0d459a61ebcf2a4f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/BlockedContactsActivity.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcContactsLoader; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; +import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class BlockedContactsActivity extends PassphraseRequiredActionBarActivity { + + @Override + public void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.activity_blocked_contacts); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.pref_blocked_contacts); + initFragment(R.id.fragment, new BlockedAndShareContactsFragment(), getIntent().getExtras()); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + + return false; + } + + public static class BlockedAndShareContactsFragment + extends Fragment + implements LoaderManager.LoaderCallbacks, + DcEventCenter.DcEventDelegate, ContactSelectionListAdapter.ItemClickListener { + + + private RecyclerView recyclerView; + private TextView emptyStateView; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); + recyclerView = ViewUtil.findById(view, R.id.recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(recyclerView); + + emptyStateView = ViewUtil.findById(view, android.R.id.empty); + emptyStateView.setText(R.string.blocked_empty_hint); + return view; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + initializeAdapter(); + } + + private void initializeAdapter() { + ContactSelectionListAdapter adapter = new ContactSelectionListAdapter(getActivity(), + GlideApp.with(this), + this, + false, + false); + recyclerView.setAdapter(adapter); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new DcContactsLoader(getActivity(), -1, null, false, false, false, true); + } + + @Override + public void onLoadFinished(Loader loader, DcContactsLoader.Ret data) { + ContactSelectionListAdapter adapter = getContactSelectionListAdapter(); + if (adapter != null) { + adapter.changeData(data); + if (emptyStateView != null) { + emptyStateView.setVisibility(adapter.getItemCount() > 0 ? View.GONE : View.VISIBLE); + } + } + } + + @Override + public void onLoaderReset(Loader loader) { + getContactSelectionListAdapter().changeData(null); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId()==DcContext.DC_EVENT_CONTACTS_CHANGED) { + restartLoader(); + } + } + + private void restartLoader() { + getLoaderManager().restartLoader(0, null, BlockedAndShareContactsFragment.this); + } + + private ContactSelectionListAdapter getContactSelectionListAdapter() { + return (ContactSelectionListAdapter) recyclerView.getAdapter(); + } + + @Override + public void onItemClick(ContactSelectionListItem item, boolean handleActionMode) { + new AlertDialog.Builder(getActivity()) + .setMessage(R.string.ask_unblock_contact) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.menu_unblock_contact, (dialog, which) -> unblockContact(item.getContactId())).show(); + } + + private void unblockContact(int contactId) { + DcContext dcContext = DcHelper.getContext(getContext()); + dcContext.blockContact(contactId, 0); + restartLoader(); + } + + @Override + public void onItemLongClick(ContactSelectionListItem view) {} + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConnectivityActivity.java b/src/main/java/org/thoughtcrime/securesms/ConnectivityActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..ebf0cc06f2494014f85dc02587ffda2fe52e57d7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConnectivityActivity.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.Menu; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; + +public class ConnectivityActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate { + @Override + protected void onCreate(Bundle state, boolean ready) { + super.onCreate(state, ready); + setForceDark(); + getSupportActionBar().setTitle(R.string.connectivity); + refresh(); + + DcHelper.getEventCenter(this).addObserver(DcContext.DC_EVENT_CONNECTIVITY_CHANGED, this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + // do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed + return true; + } + + private void refresh() { + final String connectivityHtml = DcHelper.getContext(this).getConnectivityHtml() + .replace("", " html { color-scheme: dark light; }"); + webView.loadDataWithBaseURL(null, connectivityHtml, "text/html", "utf-8", null); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + refresh(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ContactMultiSelectionActivity.java b/src/main/java/org/thoughtcrime/securesms/ContactMultiSelectionActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..9654c0249c047c5df277e54f2e6a399dd6b471b2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ContactMultiSelectionActivity.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import java.util.ArrayList; +import java.util.List; + +/** + * Activity container for selecting a list of contacts. + * + * @author Moxie Marlinspike + * + */ +public class ContactMultiSelectionActivity extends ContactSelectionActivity { + + public static final String CONTACTS_EXTRA = "contacts_extra"; + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + getIntent().putExtra(ContactSelectionListFragment.MULTI_SELECT, true); + super.onCreate(icicle, ready); + getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + + // it's a bit confusing having one "X" button on the left and one on the right - + // and the "clear search" button is not that important. + getToolbar().setUseClearButton(false); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.add_members, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + if (item.getItemId() == R.id.menu_add_members) { + saveSelection(); + finish(); + return true; + } + + return false; + } + + private void saveSelection() { + Intent resultIntent = getIntent(); + List selectedContacts = contactsFragment.getSelectedContacts(); + resultIntent.putIntegerArrayListExtra(CONTACTS_EXTRA, new ArrayList<>(selectedContacts)); + setResult(RESULT_OK, resultIntent); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..38c3ef79efba8f0f2752f5b2b80a7da4e44d57b3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.MenuItem; + +import org.thoughtcrime.securesms.components.ContactFilterToolbar; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * Base activity container for selecting a list of contacts. + * + * @author Moxie Marlinspike + * + */ +public abstract class ContactSelectionActivity extends PassphraseRequiredActionBarActivity + implements ContactSelectionListFragment.OnContactSelectedListener +{ + private static final String TAG = ContactSelectionActivity.class.getSimpleName(); + + protected ContactSelectionListFragment contactsFragment; + + private ContactFilterToolbar toolbar; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + setContentView(R.layout.contact_selection_activity); + + initializeToolbar(); + initializeResources(); + initializeSearch(); + } + + protected ContactFilterToolbar getToolbar() { + return toolbar; + } + + private void initializeToolbar() { + this.toolbar = ViewUtil.findById(this, R.id.toolbar); + setSupportActionBar(toolbar); + + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowTitleEnabled(false); + getSupportActionBar().setIcon(null); + getSupportActionBar().setLogo(null); + } + + private void initializeResources() { + contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + contactsFragment.setOnContactSelectedListener(this); + } + + private void initializeSearch() { + toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case android.R.id.home: super.onBackPressed(); return true; + } + + return false; + } + + @Override + public void onContactSelected(int contactId) {} + + @Override + public void onContactDeselected(int contactId) {} +} diff --git a/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..e7918ca01360c0ffa6ba70e2b9d82530752b741a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + + +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.SparseIntArray; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcContactsLoader; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; +import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; +import org.thoughtcrime.securesms.contacts.NewContactActivity; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * Fragment for selecting a one or more contacts from a list. + * + * @author Moxie Marlinspike + * + */ +public class ContactSelectionListFragment extends Fragment + implements LoaderManager.LoaderCallbacks, + DcEventCenter.DcEventDelegate +{ + private static final String TAG = ContactSelectionListFragment.class.getSimpleName(); + + public static final String MULTI_SELECT = "multi_select"; + public static final String SELECT_UNENCRYPTED_EXTRA = "select_unencrypted_extra"; + public static final String ALLOW_CREATION = "allow_creation"; + public static final String PRESELECTED_CONTACTS = "preselected_contacts"; + public static final int CONTACT_ADDR_RESULT_CODE = 61123; + + private DcContext dcContext; + + private Set selectedContacts; + private OnContactSelectedListener onContactSelectedListener; + private String cursorFilter; + private RecyclerView recyclerView; + private ActionMode actionMode; + private ActionMode.Callback actionModeCallback; + + @Override + public void onActivityCreated(Bundle icicle) { + super.onActivityCreated(icicle); + + dcContext = DcHelper.getContext(getActivity()); + DcHelper.getEventCenter(getActivity()).addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + initializeCursor(); + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(getActivity()).removeObservers(this); + super.onDestroy(); + } + + @Override + public void onStart() { + super.onStart(); + this.getLoaderManager().initLoader(0, null, this); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); + + recyclerView = ViewUtil.findById(view, R.id.recycler_view); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(recyclerView, true, false, true, true); + + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + actionModeCallback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + MenuInflater inflater = getActivity().getMenuInflater(); + inflater.inflate(R.menu.contact_list, menu); + setCorrectMenuVisibility(menu); + actionMode.setTitle("1"); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.menu_select_all) { + handleSelectAll(); + return true; + } else if (itemId == R.id.menu_view_profile) { + handleViewProfile(); + return true; + } else if (itemId == R.id.menu_delete_selected) { + handleDeleteSelected(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + ContactSelectionListFragment.this.actionMode = null; + getContactSelectionListAdapter().resetActionModeSelection(); + } + }; + + return view; + } + + private void handleSelectAll() { + getContactSelectionListAdapter().selectAll(); + updateActionModeTitle(); + } + + private void updateActionModeTitle() { + actionMode.setTitle(String.valueOf(getContactSelectionListAdapter().getActionModeSelection().size())); + } + + private void setCorrectMenuVisibility(Menu menu) { + ContactSelectionListAdapter adapter = getContactSelectionListAdapter(); + if (adapter.getActionModeSelection().size() > 1) { + menu.findItem(R.id.menu_view_profile).setVisible(false); + } else { + menu.findItem(R.id.menu_view_profile).setVisible(true); + } + } + + private void handleViewProfile() { + ContactSelectionListAdapter adapter = getContactSelectionListAdapter(); + if (adapter.getActionModeSelection().size() == 1) { + int contactId = adapter.getActionModeSelection().valueAt(0); + + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, contactId); + getContext().startActivity(intent); + } + } + + private void handleDeleteSelected() { + AlertDialog dialog = new AlertDialog.Builder(getActivity()) + .setMessage(R.string.ask_delete_contacts) + .setPositiveButton(R.string.delete, (d, i) -> { + ContactSelectionListAdapter adapter = getContactSelectionListAdapter(); + final SparseIntArray actionModeSelection = adapter.getActionModeSelection().clone(); + new Thread(() -> { + for (int index = 0; index < actionModeSelection.size(); index++) { + int contactId = actionModeSelection.valueAt(index); + dcContext.deleteContact(contactId); + } + }).start(); + adapter.resetActionModeSelection(); + actionMode.finish(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + Util.redPositiveButton(dialog); + } + + private ContactSelectionListAdapter getContactSelectionListAdapter() { + return (ContactSelectionListAdapter) recyclerView.getAdapter(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + public @NonNull List getSelectedContacts() { + List selected = new LinkedList<>(); + if (selectedContacts != null) { + selected.addAll(selectedContacts); + } + + return selected; + } + + private boolean isMulti() { + return getActivity().getIntent().getBooleanExtra(MULTI_SELECT, false); + } + + private boolean isUnencrypted() { + return getActivity().getIntent().getBooleanExtra(SELECT_UNENCRYPTED_EXTRA, false); + } + + private void initializeCursor() { + ContactSelectionListAdapter adapter = new ContactSelectionListAdapter(getActivity(), + GlideApp.with(this), + new ListClickListener(), + isMulti(), + true); + selectedContacts = adapter.getSelectedContacts(); + ArrayList preselectedContacts = getActivity().getIntent().getIntegerArrayListExtra(PRESELECTED_CONTACTS); + if(preselectedContacts!=null) { + selectedContacts.addAll(preselectedContacts); + } + recyclerView.setAdapter(adapter); + } + + public void setQueryFilter(String filter) { + this.cursorFilter = filter; + this.getLoaderManager().restartLoader(0, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + final boolean allowCreation = getActivity().getIntent().getBooleanExtra(ALLOW_CREATION, true); + final boolean addCreateContactLink = allowCreation && isUnencrypted(); + final boolean addCreateGroupLinks = allowCreation && !isRelayingMessageContent(getActivity()) && !isMulti(); + final boolean addScanQRLink = allowCreation && !isMulti(); + + final int listflags = DcContext.DC_GCL_ADD_SELF | (isUnencrypted()? DcContext.DC_GCL_ADDRESS : 0); + return new DcContactsLoader(getActivity(), listflags, cursorFilter, addCreateGroupLinks, addCreateContactLink, addScanQRLink, false); + } + + @Override + public void onLoadFinished(Loader loader, DcContactsLoader.Ret data) { + ((ContactSelectionListAdapter) recyclerView.getAdapter()).changeData(data); + } + + @Override + public void onLoaderReset(Loader loader) { + ((ContactSelectionListAdapter) recyclerView.getAdapter()).changeData(null); + } + + private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener { + @Override + public void onItemClick(ContactSelectionListItem contact, boolean handleActionMode) + { + if (handleActionMode) { + if (actionMode != null) { + Menu menu = actionMode.getMenu(); + setCorrectMenuVisibility(menu); + updateActionModeTitle(); + finishActionModeIfSelectionIsEmpty(); + } + return; + } + int contactId = contact.getSpecialId(); + if (!isMulti() || !selectedContacts.contains(contactId)) { + if (contactId == DcContact.DC_CONTACT_ID_NEW_CLASSIC_CONTACT) { + Intent intent = new Intent(getContext(), NewContactActivity.class); + if (dcContext.mayBeValidAddr(cursorFilter)) { + intent.putExtra(NewContactActivity.ADDR_EXTRA, cursorFilter); + } + if (isMulti()) { + startActivityForResult(intent, CONTACT_ADDR_RESULT_CODE); + } else { + requireContext().startActivity(intent); + } + return; + } + + selectedContacts.add(contactId); + contact.setChecked(true); + if (onContactSelectedListener != null) { + onContactSelectedListener.onContactSelected(contactId); + } + } else { + selectedContacts.remove(contactId); + contact.setChecked(false); + if (onContactSelectedListener != null) { + onContactSelectedListener.onContactDeselected(contactId); + } + } + } + + @Override + public void onItemLongClick(ContactSelectionListItem view) { + if (actionMode == null) { + actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); + } else { + finishActionModeIfSelectionIsEmpty(); + } + } + } + + private void finishActionModeIfSelectionIsEmpty() { + if (getContactSelectionListAdapter().getActionModeSelection().size() == 0) { + actionMode.finish(); + } + } + + public void setOnContactSelectedListener(OnContactSelectedListener onContactSelectedListener) { + this.onContactSelectedListener = onContactSelectedListener; + } + + public interface OnContactSelectedListener { + void onContactSelected(int contactId); + void onContactDeselected(int contactId); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId()==DcContext.DC_EVENT_CONTACTS_CHANGED) { + getLoaderManager().restartLoader(0, null, ContactSelectionListFragment.this); + } + } + + @Override + public void onActivityResult(int reqCode, int resultCode, final Intent data) { + super.onActivityResult(reqCode, resultCode, data); + if (resultCode == Activity.RESULT_OK && reqCode == CONTACT_ADDR_RESULT_CODE) { + int contactId = data.getIntExtra(NewContactActivity.CONTACT_ID_EXTRA, 0); + if (contactId != 0) { + selectedContacts.add(contactId); + } + getLoaderManager().restartLoader(0, null, ContactSelectionListFragment.this); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..2ee535d5d08bfb5632876c23da43404744a8ac35 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -0,0 +1,1677 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.TransportOption.Type; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedText; +import static org.thoughtcrime.securesms.util.ShareUtil.isForwarding; +import static org.thoughtcrime.securesms.util.ShareUtil.isSharing; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Vibrator; +import android.provider.Browser; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnFocusChangeListener; +import android.view.View.OnKeyListener; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.WindowCompat; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.audio.AudioRecorder; +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.components.AttachmentTypeSelector; +import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.components.HidingLinearLayout; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.components.InputPanel; +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; +import org.thoughtcrime.securesms.components.ScaleStableImageView; +import org.thoughtcrime.securesms.components.SendButton; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.connect.DirectShareUtil; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.SendRelayedMessageUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.guava.Optional; +import org.thoughtcrime.securesms.util.views.ProgressDialog; +import org.thoughtcrime.securesms.video.recode.VideoRecoder; +import org.thoughtcrime.securesms.calls.CallUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + +/** + * Activity for displaying a message thread, as well as + * composing/sending a new message into that thread. + * + * @author Moxie Marlinspike + * + */ +@SuppressLint("StaticFieldLeak") +public class ConversationActivity extends PassphraseRequiredActionBarActivity + implements ConversationFragment.ConversationFragmentListener, + AttachmentManager.AttachmentListener, + SearchView.OnQueryTextListener, + DcEventCenter.DcEventDelegate, + OnKeyboardShownListener, + InputPanel.Listener, + InputPanel.MediaListener +{ + private static final String TAG = ConversationActivity.class.getSimpleName(); + + public static final String ACCOUNT_ID_EXTRA = "account_id"; + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String FROM_ARCHIVED_CHATS_EXTRA = "from_archived"; + public static final String TEXT_EXTRA = "draft_text"; + public static final String MSG_TYPE_EXTRA = "msg_type"; + public static final String MSG_HTML_EXTRA = "msg_html"; + public static final String MSG_SUBJECT_EXTRA = "msg_subject"; + public static final String STARTING_POSITION_EXTRA = "starting_position"; + + private static final int PICK_GALLERY = 1; + private static final int PICK_DOCUMENT = 2; + private static final int PICK_CONTACT = 4; + private static final int GROUP_EDIT = 6; + private static final int TAKE_PHOTO = 7; + private static final int RECORD_VIDEO = 8; + private static final int PICK_WEBXDC = 9; + + private GlideRequests glideRequests; + protected ComposeText composeText; + private AnimatingToggle buttonToggle; + private SendButton sendButton; + private ImageButton attachButton; + protected ConversationTitleView titleView; + private ConversationFragment fragment; + private InputAwareLayout container; + private View composePanel; + private ScaleStableImageView backgroundView; + private MessageRequestsBottomView messageRequestBottomView; + private ProgressDialog progressDialog; + + private AttachmentTypeSelector attachmentTypeSelector; + private AttachmentManager attachmentManager; + private AudioRecorder audioRecorder; + private FrameLayout emojiPickerContainer; + private MediaKeyboard emojiPicker; + protected HidingLinearLayout quickAttachmentToggle; + private InputPanel inputPanel; + + private ApplicationContext context; + private Recipient recipient; + private DcContext dcContext; + private Rpc rpc; + private DcChat dcChat = new DcChat(0, 0); + private int chatId; + private final boolean isSecureText = true; + private boolean isDefaultSms = true; + private boolean isSecurityInitialized = false; + private boolean successfulForwardingAttempt = false; + private boolean isEditing = false; + + @Override + protected void onCreate(Bundle state, boolean ready) { + this.context = ApplicationContext.getInstance(getApplicationContext()); + this.dcContext = DcHelper.getContext(context); + this.rpc = DcHelper.getRpc(context); + + supportRequestWindowFeature(WindowCompat.FEATURE_ACTION_BAR_OVERLAY); + setContentView(R.layout.conversation_activity); + + TypedArray typedArray = obtainStyledAttributes(new int[] {R.attr.conversation_background}); + int color = typedArray.getColor(0, Color.WHITE); + typedArray.recycle(); + + getWindow().getDecorView().setBackgroundColor(color); + + fragment = initFragment(R.id.fragment_content, new ConversationFragment()); + + initializeActionBar(); + initializeViews(); + initializeResources(); + initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + initializeDraft().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + if (result != null && result) { + Util.runOnMain(() -> { + if (fragment != null && fragment.isResumed()) { + fragment.moveToLastSeen(); + } else { + Log.w(TAG, "Wanted to move to the last seen position, but the fragment was in an invalid state"); + } + }); + } + } + }); + } + }); + + DcEventCenter eventCenter = DcHelper.getEventCenter(this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + + if (!isMultiUser()) { + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this); + } + + handleRelaying(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + if (isFinishing()) { + return; + } + + if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent()) { + processComposeControls(ACTION_SAVE_DRAFT); + attachmentManager.clear(glideRequests, false); + composeText.setText(""); + } + + setIntent(intent); + initializeResources(); + initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + initializeDraft(); + } + }); + + handleRelaying(); + + if (fragment != null) { + fragment.onNewIntent(); + } + } + + private void handleRelaying() { + if (isForwarding(this)) { + handleForwarding(); + } else if (isSharing(this)) { + handleSharing(); + } + + ConversationListRelayingActivity.finishActivity(); + } + + @Override + protected void onResume() { + super.onResume(); + + initializeEnabledCheck(); + composeText.setTransport(sendButton.getSelectedTransport()); + + titleView.setTitle(glideRequests, dcChat); + + DcHelper.getNotificationCenter(this).updateVisibleChat(dcContext.getAccountId(), chatId); + + attachmentManager.onResume(); + } + + @Override + protected void onPause() { + super.onPause(); + + processComposeControls(ACTION_SAVE_DRAFT); + + DcHelper.getNotificationCenter(this).clearVisibleChat(); + if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right); + inputPanel.onPause(); + AudioSlidePlayer.stopAll(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.i(TAG, "onConfigurationChanged(" + newConfig.orientation + ")"); + super.onConfigurationChanged(newConfig); + composeText.setTransport(sendButton.getSelectedTransport()); + + if (emojiPicker != null && container.getCurrentInput() == emojiPicker) { + container.hideAttachedInput(true); + } + + emojiPicker = null; // force reloading next time onEmojiToggle() is called + initializeBackground(); + } + + @Override + protected void onDestroy() { + DcHelper.getEventCenter(this).removeObservers(this); + super.onDestroy(); + } + + @Override + public void onActivityResult(final int reqCode, int resultCode, Intent data) { + super.onActivityResult(reqCode, resultCode, data); + + if (resultCode != RESULT_OK || (data == null && reqCode != TAKE_PHOTO && reqCode != RECORD_VIDEO)) + { + return; + } + + switch (reqCode) { + case PICK_GALLERY: + final Uri singleUri = data.getData(); + if (singleUri != null) { + MediaType mediaType; + String mimeType = MediaUtil.getMimeType(this, singleUri); + if (MediaUtil.isGif(mimeType)) mediaType = MediaType.GIF; + else if (MediaUtil.isVideo(mimeType)) mediaType = MediaType.VIDEO; + else mediaType = MediaType.IMAGE; + setMedia(singleUri, mediaType); + } else { + final ClipData multipleUris = data.getClipData(); + if (multipleUris != null) { + final int uriCount = multipleUris.getItemCount(); + if (uriCount > 0) { + ArrayList uriList = new ArrayList<>(uriCount); + for (int i = 0; i < uriCount; i++) { + uriList.add(multipleUris.getItemAt(i).getUri()); + } + askSendingFiles(uriList, () -> { + Util.runOnAnyBackgroundThread(() -> { + SendRelayedMessageUtil.sendMultipleMsgs(this, chatId, uriList, null); + }); + }); + } + } + } + break; + + case PICK_DOCUMENT: + final String docMimeType = MediaUtil.getMimeType(this, data.getData()); + final MediaType docMediaType = MediaUtil.isAudioType(docMimeType) ? MediaType.AUDIO : MediaType.DOCUMENT; + setMedia(data.getData(), docMediaType); + break; + + case PICK_WEBXDC: + setMedia(data.getData(), MediaType.DOCUMENT); + break; + + case PICK_CONTACT: + addAttachmentContactInfo(data.getIntExtra(AttachContactActivity.CONTACT_ID_EXTRA, 0)); + break; + + case GROUP_EDIT: + dcChat = dcContext.getChat(chatId); + titleView.setTitle(glideRequests, dcChat); + break; + + case TAKE_PHOTO: + if (attachmentManager.getImageCaptureUri() != null) { + setMedia(attachmentManager.getImageCaptureUri(), MediaType.IMAGE); + } + break; + + case RECORD_VIDEO: + Uri uri = null; + if (data!=null) { uri = data.getData(); } + if (uri==null) { uri = attachmentManager.getVideoCaptureUri(); } + if (uri!=null) { + setMedia(uri, MediaType.VIDEO); + } + else { + Toast.makeText(this, "No video returned from system", Toast.LENGTH_LONG).show(); + } + break; + + case ScribbleActivity.SCRIBBLE_REQUEST_CODE: + setMedia(data.getData(), MediaType.IMAGE); + break; + } + } + + @Override + public void startActivity(Intent intent) { + if (intent.getStringExtra(Browser.EXTRA_APPLICATION_ID) != null) { + intent.removeExtra(Browser.EXTRA_APPLICATION_ID); + } + + try { + super.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.w(TAG, e); + Toast.makeText(this, R.string.no_app_to_handle_data, Toast.LENGTH_LONG).show(); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + + getMenuInflater().inflate(R.menu.conversation, menu); + + if (dcChat.isSelfTalk() || dcChat.isOutBroadcast()) { + menu.findItem(R.id.menu_mute_notifications).setVisible(false); + } else if(dcChat.isMuted()) { + menu.findItem(R.id.menu_mute_notifications).setTitle(R.string.menu_unmute); + } + + if (!Prefs.isLocationStreamingEnabled(this)) { + menu.findItem(R.id.menu_show_map).setVisible(false); + } + + menu.findItem(R.id.menu_start_call).setVisible( + Prefs.isCallsEnabled(this) + && dcChat.canSend() + && dcChat.isEncrypted() + && !dcChat.isSelfTalk() + && !dcChat.isMultiUser() + ); + + if (!dcChat.isEncrypted() || !dcChat.canSend() || dcChat.isMailingList() ) { + menu.findItem(R.id.menu_ephemeral_messages).setVisible(false); + } + + if (isMultiUser()) { + if (dcChat.isInBroadcast() && !dcChat.isContactRequest()) { + menu.findItem(R.id.menu_leave).setTitle(R.string.menu_leave_channel).setVisible(true); + } else if (dcChat.isEncrypted() + && dcChat.canSend() + && !dcChat.isOutBroadcast() + && !dcChat.isMailingList()) { + menu.findItem(R.id.menu_leave).setVisible(true); + } + } + + if (isArchived()) { + menu.findItem(R.id.menu_archive_chat).setTitle(R.string.menu_unarchive_chat); + } + + + Util.redMenuItem(menu, R.id.menu_leave); + Util.redMenuItem(menu, R.id.menu_clear_chat); + Util.redMenuItem(menu, R.id.menu_delete_chat); + + try { + MenuItem searchItem = menu.findItem(R.id.menu_search_chat); + searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(final MenuItem item) { + searchExpand(menu, item); + return true; + } + + @Override + public boolean onMenuItemActionCollapse(final MenuItem item) { + searchCollapse(menu, item); + return true; + } + }); + SearchView searchView = (SearchView) searchItem.getActionView(); + searchView.setOnQueryTextListener(this); + searchView.setQueryHint(getString(R.string.search)); + searchView.setIconifiedByDefault(true); + + // hide the [X] beside the search field - this is too much noise, search can be aborted eg. by "back" + ImageView closeBtn = searchView.findViewById(R.id.search_close_btn); + if (closeBtn!=null) { + closeBtn.setEnabled(false); + closeBtn.setImageDrawable(null); + } + } catch (Exception e) { + Log.e(TAG, "cannot set up in-chat-search: ", e); + } + + if (!dcChat.canSend() || isEditing) { + MenuItem attachItem = menu.findItem(R.id.menu_add_attachment); + if (attachItem!=null) { + attachItem.setVisible(false); + } + } + + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == R.id.menu_add_attachment) { + handleAddAttachment(); + return true; + } else if (itemId == R.id.menu_leave) { + handleLeaveGroup(); + return true; + } else if (itemId == R.id.menu_archive_chat) { + handleArchiveChat(); + return true; + } else if (itemId == R.id.menu_clear_chat) { + fragment.handleClearChat(); + return true; + } else if (itemId == R.id.menu_delete_chat) { + handleDeleteChat(); + return true; + } else if (itemId == R.id.menu_mute_notifications) { + handleMuteNotifications(); + return true; + } else if (itemId == R.id.menu_show_map) { + WebxdcActivity.openMaps(this, chatId); + return true; + } else if (itemId == R.id.menu_start_call) { + CallUtil.startCall(this, chatId); + return true; + } else if (itemId == R.id.menu_all_media) { + handleAllMedia(); + return true; + } else if (itemId == R.id.menu_search_up) { + handleMenuSearchNext(false); + return true; + } else if (itemId == R.id.menu_search_down) { + handleMenuSearchNext(true); + return true; + } else if (itemId == android.R.id.home) { + handleReturnToConversationList(); + return true; + } else if (itemId == R.id.menu_ephemeral_messages) { + handleEphemeralMessages(); + return true; + } + + return false; + } + + @Override + public void onBackPressed() { + if (container.isInputOpen()){ + container.hideCurrentInput(composeText); + } else { + handleReturnToConversationList(); + } + } + + @Override + public void onKeyboardShown() { + inputPanel.onKeyboardShown(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + public void setDraftText(String txt) { + composeText.setText(txt); + composeText.setSelection(composeText.getText().length()); + } + + public void hideSoftKeyboard() { + container.hideCurrentInput(composeText); + } + + //////// Event Handlers + + private void handleEphemeralMessages() { + int preselected = dcContext.getChatEphemeralTimer(chatId); + EphemeralMessagesDialog.show(this, preselected, duration -> { + dcContext.setChatEphemeralTimer(chatId, (int) duration); + }); + } + + private void handleReturnToConversationList() { + handleReturnToConversationList(null); + } + + private void handleReturnToConversationList(@Nullable Bundle extras) { + boolean archived = getIntent().getBooleanExtra(FROM_ARCHIVED_CHATS_EXTRA, false); + Intent intent = new Intent(this, (archived ? ConversationListArchiveActivity.class : ConversationListActivity.class)); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + if (extras != null) intent.putExtras(extras); + startActivity(intent); + finish(); + } + + private void handleMuteNotifications() { + if(!dcChat.isMuted()) { + MuteDialog.show(this, duration -> { + dcContext.setChatMuteDuration(chatId, duration); + titleView.setTitle(glideRequests, dcChat); + }); + } else { + // unmute + dcContext.setChatMuteDuration(chatId, 0); + titleView.setTitle(glideRequests, dcChat); + } + } + + private void handleProfile() { + Intent intent = new Intent(this, ProfileActivity.class); + intent.putExtra(ProfileActivity.CHAT_ID_EXTRA, chatId); + startActivity(intent); + } + + private void handleAllMedia() { + Intent intent = new Intent(this, AllMediaActivity.class); + intent.putExtra(AllMediaActivity.CHAT_ID_EXTRA, chatId); + startActivity(intent); + } + + private void handleLeaveGroup() { + @StringRes int leaveLabel; + if (dcChat.isInBroadcast()) { + leaveLabel = R.string.menu_leave_channel; + } else { + leaveLabel = R.string.menu_leave_group; + } + + AlertDialog dialog = new AlertDialog.Builder(this) + .setMessage(getString(R.string.ask_leave_group)) + .setPositiveButton(leaveLabel, (d, which) -> { + dcContext.removeContactFromChat(chatId, DcContact.DC_CONTACT_ID_SELF); + Toast.makeText(this, getString(R.string.done), Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + Util.redPositiveButton(dialog); + } + + private void handleArchiveChat() { + int newVisibility = isArchived() ? + DcChat.DC_CHAT_VISIBILITY_NORMAL : DcChat.DC_CHAT_VISIBILITY_ARCHIVED; + dcContext.setChatVisibility(chatId, newVisibility); + Toast.makeText(this, getString(R.string.done), Toast.LENGTH_SHORT).show(); + if (newVisibility==DcChat.DC_CHAT_VISIBILITY_ARCHIVED) { + finish(); + return; + } + dcChat = dcContext.getChat(chatId); + } + + private void handleDeleteChat() { + AlertDialog dialog = new AlertDialog.Builder(this) + .setMessage(getResources().getString(R.string.ask_delete_named_chat, dcChat.getName())) + .setPositiveButton(R.string.delete, (d, which) -> { + dcContext.deleteChat(chatId); + DirectShareUtil.clearShortcut(this, chatId); + finish(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + Util.redPositiveButton(dialog); + } + + private void handleAddAttachment() { + if (attachmentTypeSelector == null) { + attachmentTypeSelector = new AttachmentTypeSelector(this, getSupportLoaderManager(), new AttachmentTypeListener(), chatId); + } + attachmentTypeSelector.show(this, attachButton); + } + + private void handleSecurityChange(boolean isSecureText, boolean isDefaultSms) { + Log.i(TAG, "handleSecurityChange(" + isSecureText + ", " + isDefaultSms + ")"); + if (isSecurityInitialized && isSecureText == this.isSecureText && isDefaultSms == this.isDefaultSms) { + return; + } + + this.isDefaultSms = isDefaultSms; + this.isSecurityInitialized = true; + + sendButton.resetAvailableTransports(); + sendButton.setDefaultTransport(Type.NORMAL_MAIL); + } + + private void handleForwarding() { + DcChat dcChat = dcContext.getChat(chatId); + if (dcChat.isSelfTalk()) { + SendRelayedMessageUtil.immediatelyRelay(this, chatId); + } else { + String name = dcChat.getName(); + if (!dcChat.isMultiUser()) { + int[] contactIds = dcContext.getChatContacts(chatId); + if (contactIds.length == 1 || contactIds.length == 2) { + name = dcContext.getContact(contactIds[0]).getDisplayName(); + } + } + new AlertDialog.Builder(this) + .setMessage(getString(R.string.ask_forward, name)) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + SendRelayedMessageUtil.immediatelyRelay(this, chatId); + successfulForwardingAttempt = true; + }) + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> finish()) + .setOnCancelListener(dialog -> finish()) + .show(); + } + } + + private void askSendingFiles(ArrayList uriList, Runnable onConfirm) { + String message = String.format(getString(R.string.ask_send_files_to_chat), uriList.size(), dcChat.getName()); + if (SendRelayedMessageUtil.containsVideoType(context, uriList)) { + message += "\n\n" + getString(R.string.videos_sent_without_recoding); + } + new AlertDialog.Builder(this) + .setMessage(message) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.menu_send, (dialog, which) -> onConfirm.run()) + .show(); + } + + private void handleSharing() { + ArrayList uriList = ShareUtil.getSharedUris(this); + int sharedContactId = ShareUtil.getSharedContactId(this); + if (uriList.size() > 1) { + askSendingFiles(uriList, () -> SendRelayedMessageUtil.immediatelyRelay(this, chatId)); + } else { + if (sharedContactId != 0) { + addAttachmentContactInfo(sharedContactId); + } else if (ShareUtil.getSharedHtml(this) != null || ShareUtil.getSharedSubject(this) != null || ("sticker".equals(ShareUtil.getSharedType(this)) && !uriList.isEmpty())) { + SendRelayedMessageUtil.immediatelyRelay(this, chatId); + } else { + Uri uri = uriList.isEmpty()? null : uriList.get(0); + dcContext.setDraft(chatId, SendRelayedMessageUtil.createMessage(this, uri, ShareUtil.getSharedType(this), null, null, getSharedText(this))); + } + initializeDraft(); + } + } + + ///// Initializers + + /** + * Drafts can be initialized by click on a mailto: link or from the database + * @return + */ + private ListenableFuture initializeDraft() { + isEditing = false; + final SettableFuture future = new SettableFuture<>(); + DcMsg draft = dcContext.getDraft(chatId); + final String sharedText = ShareUtil.getSharedText(this); + + if (!draft.isOk()) { + if (TextUtils.isEmpty(sharedText)) { + composeText.setText(""); + future.set(false); + } else { + composeText.setText(sharedText); + future.set(true); + } + updateToggleButtonState(); + return future; + } + + final String text = TextUtils.isEmpty(sharedText)? draft.getText() : sharedText; + if(!text.isEmpty()) { + composeText.setText(text); + composeText.setSelection(composeText.getText().length()); + } else { + composeText.setText(""); + } + + DcMsg quote = draft.getQuotedMsg(); + if (quote == null) { + inputPanel.clearQuoteWithoutAnimation(); + } else { + handleReplyMessage(quote); + } + + String file = draft.getFile(); + if (file.isEmpty() || !new File(file).exists()) { + future.set(!text.isEmpty()); + updateToggleButtonState(); + return future; + } + + ListenableFuture.Listener listener = new ListenableFuture.Listener() { + @Override + public void onSuccess(Boolean result) { + future.set(result || !text.isEmpty()); + updateToggleButtonState(); + } + + @Override + public void onFailure(ExecutionException e) { + future.set(!text.isEmpty()); + updateToggleButtonState(); + } + }; + + switch (draft.getType()) { + case DcMsg.DC_MSG_IMAGE: + setMedia(draft, MediaType.IMAGE).addListener(listener); + break; + case DcMsg.DC_MSG_GIF: + setMedia(draft, MediaType.GIF).addListener(listener); + break; + case DcMsg.DC_MSG_AUDIO: + setMedia(draft, MediaType.AUDIO).addListener(listener); + break; + case DcMsg.DC_MSG_VIDEO: + setMedia(draft, MediaType.VIDEO).addListener(listener); + break; + default: + setMedia(draft, MediaType.DOCUMENT).addListener(listener); + break; + } + + return future; + } + + private void initializeEnabledCheck() { + boolean enabled = true; + inputPanel.setEnabled(enabled); + sendButton.setEnabled(enabled); + attachButton.setEnabled(enabled); + } + + private ListenableFuture initializeSecurity(final boolean currentSecureText, + final boolean currentIsDefaultSms) + { + final SettableFuture future = new SettableFuture<>(); + + handleSecurityChange(currentSecureText || isMultiUser(), currentIsDefaultSms); + + future.set(true); + return future; + } + + private void initializeViews() { + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar == null) throw new AssertionError(); + + titleView = (ConversationTitleView) supportActionBar.getCustomView(); + buttonToggle = ViewUtil.findById(this, R.id.button_toggle); + sendButton = ViewUtil.findById(this, R.id.send_button); + attachButton = ViewUtil.findById(this, R.id.attach_button); + composeText = ViewUtil.findById(this, R.id.embedded_text_editor); + emojiPickerContainer = ViewUtil.findById(this, R.id.emoji_picker_container); + composePanel = ViewUtil.findById(this, R.id.bottom_panel); + container = ViewUtil.findById(this, R.id.layout_container); + quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle); + inputPanel = ViewUtil.findById(this, R.id.bottom_panel); + backgroundView = ViewUtil.findById(this, R.id.conversation_background); + messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar); + + ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle); + + if (!ViewUtil.isEdgeToEdgeSupported()) { + // since insets will not be applied, we need to set top padding to avoid drawing behind toolbar + try (TypedArray typedArray = obtainStyledAttributes(new int[]{android.R.attr.actionBarSize})) { + int paddingTop = typedArray.getDimensionPixelSize(0, 0); + container.setPadding(container.getPaddingLeft(), paddingTop, container.getPaddingRight() , container.getPaddingBottom()); + } + } + // apply padding top to avoid drawing behind top bar + ViewUtil.applyWindowInsets(findViewById(R.id.fragment_content), false, true, false, false); + // apply padding to root to avoid collision with system bars + ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); + + container.addOnKeyboardShownListener(this); + container.addOnKeyboardHiddenListener(backgroundView); + container.addOnKeyboardShownListener(backgroundView); + inputPanel.setListener(this); + inputPanel.setMediaListener(this); + + attachmentTypeSelector = null; + attachmentManager = new AttachmentManager(this, this); + audioRecorder = new AudioRecorder(this); + + SendButtonListener sendButtonListener = new SendButtonListener(); + ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); + + composeText.setOnEditorActionListener(sendButtonListener); + attachButton.setOnClickListener(new AttachButtonListener()); + attachButton.setOnLongClickListener(new AttachButtonLongClickListener()); + sendButton.setOnClickListener(sendButtonListener); + sendButton.setEnabled(true); + sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { + composeText.setTransport(newTransport); + buttonToggle.getBackground().invalidateSelf(); + }); + + titleView.setOnClickListener(v -> handleProfile()); + titleView.setOnBackClickedListener(view -> handleReturnToConversationList()); + + composeText.setOnKeyListener(composeKeyPressedListener); + composeText.addTextChangedListener(composeKeyPressedListener); + composeText.setOnEditorActionListener(sendButtonListener); + composeText.setOnClickListener(composeKeyPressedListener); + composeText.setOnFocusChangeListener(composeKeyPressedListener); + + quickCameraToggle.setOnClickListener(v -> attachmentManager.capturePhoto(ConversationActivity.this, TAKE_PHOTO)); + + initializeBackground(); + } + + private void initializeBackground() { + String backgroundImagePath = Prefs.getBackgroundImagePath(this, dcContext.getAccountId()); + Drawable background; + if(!backgroundImagePath.isEmpty()) { + background = Drawable.createFromPath(backgroundImagePath); + } + else { + background = getResources().getDrawable(R.drawable.background_hd); + } + backgroundView.setImageDrawable(background); + } + + protected void initializeActionBar() { + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar == null) throw new AssertionError(); + + supportActionBar.setDisplayHomeAsUpEnabled(false); + supportActionBar.setCustomView(R.layout.conversation_title_view); + supportActionBar.setDisplayShowCustomEnabled(true); + supportActionBar.setDisplayShowTitleEnabled(false); + + Toolbar parent = (Toolbar) supportActionBar.getCustomView().getParent(); + parent.setPadding(0,0,0,0); + parent.setContentInsetsAbsolute(0,0); + } + + private void initializeResources() { + int accountId = getIntent().getIntExtra(ACCOUNT_ID_EXTRA, dcContext.getAccountId()); + if (accountId != dcContext.getAccountId()) { + AccountManager.getInstance().switchAccount(context, accountId); + dcContext = context.dcContext; + fragment.dcContext = context.dcContext; + initializeBackground(); + } + chatId = getIntent().getIntExtra(CHAT_ID_EXTRA, -1); + if(chatId == DcChat.DC_CHAT_NO_CHAT) + throw new IllegalStateException("can't display a conversation for no chat."); + dcChat = dcContext.getChat(chatId); + recipient = new Recipient(this, dcChat); + glideRequests = GlideApp.with(this); + + setComposePanelVisibility(); + initializeContactRequest(); + } + + private void setComposePanelVisibility() { + if (dcChat.canSend()) { + composePanel.setVisibility(View.VISIBLE); + attachmentManager.setHidden(false); + } else { + composePanel.setVisibility(View.GONE); + attachmentManager.setHidden(true); + hideSoftKeyboard(); + } + } + + //////// Helper Methods + + private void addAttachment(int type) { + switch (type) { + case AttachmentTypeSelector.ADD_GALLERY: + AttachmentManager.selectGallery(this, PICK_GALLERY); break; + case AttachmentTypeSelector.ADD_DOCUMENT: + AttachmentManager.selectDocument(this, PICK_DOCUMENT); break; + case AttachmentTypeSelector.ADD_CONTACT_INFO: + startContactChooserActivity(); break; + case AttachmentTypeSelector.ADD_LOCATION: + AttachmentManager.selectLocation(this, chatId); break; + case AttachmentTypeSelector.TAKE_PHOTO: + attachmentManager.capturePhoto(this, TAKE_PHOTO); break; + case AttachmentTypeSelector.RECORD_VIDEO: + attachmentManager.captureVideo(this, RECORD_VIDEO); + break; + case AttachmentTypeSelector.ADD_WEBXDC: + AttachmentManager.selectWebxdc(this, PICK_WEBXDC); break; + } + } + + private void startContactChooserActivity() { + Intent intent = new Intent(ConversationActivity.this, AttachContactActivity.class); + intent.putExtra(ContactSelectionListFragment.ALLOW_CREATION, false); + startActivityForResult(intent, PICK_CONTACT); + } + + private ListenableFuture setMedia(@Nullable Uri uri, @NonNull MediaType mediaType) { + if (uri == null) { + return new SettableFuture<>(false); + } + + return attachmentManager.setMedia(glideRequests, uri, null, mediaType, 0, 0, chatId); + } + + private ListenableFuture setMedia(DcMsg msg, @NonNull MediaType mediaType) { + return attachmentManager.setMedia(glideRequests, Uri.fromFile(new File(msg.getFile())), msg, mediaType, 0, 0, chatId); + } + + private void addAttachmentContactInfo(int contactId) { + if (contactId == 0) { + return; + } + + try { + byte[] vcard = rpc.makeVcard(dcContext.getAccountId(), Collections.singletonList(contactId)).getBytes(); + String mimeType = "application/octet-stream"; + setMedia(PersistentBlobProvider.getInstance().create(this, vcard, mimeType, "vcard.vcf"), MediaType.DOCUMENT); + } catch (RpcException e) { + Log.e(TAG, "makeVcard() failed", e); + } + } + + private boolean isMultiUser() { + return dcChat.isMultiUser(); + } + + private boolean isArchived() { + return dcChat.getVisibility() == DcChat.DC_CHAT_VISIBILITY_ARCHIVED; + } + + //////// send message or save draft + + protected static final int ACTION_SEND_OUT = 1; + protected static final int ACTION_SAVE_DRAFT = 2; + + protected ListenableFuture processComposeControls(int action) { + return processComposeControls(action, composeText.getTextTrimmed(), + attachmentManager.isAttachmentPresent() ? + attachmentManager.buildSlideDeck() : null); + } + + protected ListenableFuture processComposeControls(final int action, String body, SlideDeck slideDeck) { + + final SettableFuture future = new SettableFuture<>(); + + Optional quote = inputPanel.getQuote(); + boolean editing = isEditing; + + // for a quick ui feedback, we clear the related controls immediately on sending messages. + // for drafts, however, we do not change the controls, the activity may be resumed. + if (action==ACTION_SEND_OUT) { + composeText.setText(""); + inputPanel.clearQuote(); + } + + Util.runOnAnyBackgroundThread(() -> { + DcMsg msg = null; + int recompress = 0; + + if (editing) { + int msgId = quote.get().getQuotedMsg().getId(); + if (action == ACTION_SEND_OUT) { + dcContext.sendEditRequest(msgId, body); + } else { + dcContext.setDraft(chatId, null); + } + future.set(chatId); + return; + } + + if(slideDeck!=null) { + if (action==ACTION_SEND_OUT) { + Util.runOnMain(() -> attachmentManager.clear(glideRequests, false)); + } + + try { + if (slideDeck.getWebxdctDraftId() != 0) { + msg = dcContext.getDraft(chatId); + } else { + List attachments = slideDeck.asAttachments(); + for (Attachment attachment : attachments) { + String contentType = attachment.getContentType(); + if (MediaUtil.isImageType(contentType) && slideDeck.getDocumentSlide() == null) { + msg = new DcMsg(dcContext, + MediaUtil.isGif(contentType) ? DcMsg.DC_MSG_GIF : DcMsg.DC_MSG_IMAGE); + msg.setDimension(attachment.getWidth(), attachment.getHeight()); + } else if (MediaUtil.isAudioType(contentType)) { + msg = new DcMsg(dcContext, + attachment.isVoiceNote() ? DcMsg.DC_MSG_VOICE : DcMsg.DC_MSG_AUDIO); + } else if (MediaUtil.isVideoType(contentType) && slideDeck.getDocumentSlide() == null) { + msg = new DcMsg(dcContext, DcMsg.DC_MSG_VIDEO); + recompress = DcMsg.DC_MSG_VIDEO; + } else { + msg = new DcMsg(dcContext, DcMsg.DC_MSG_FILE); + } + String path = attachment.getRealPath(this); + msg.setFileAndDeduplicate(path, attachment.getFileName(), null); + } + } + if (msg != null) { + msg.setText(body); + } + } + catch(Exception e) { + e.printStackTrace(); + } + } + else if (!body.isEmpty()){ + msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + msg.setText(body); + } + + if (quote.isPresent()) { + if (msg == null) msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + msg.setQuote(quote.get().getQuotedMsg()); + } + + if (action==ACTION_SEND_OUT) { + + // for WEBXDC, drafts are just sent out as is. + // for preparations and other cases, cleanup draft soon. + if (msg == null || msg.getType() != DcMsg.DC_MSG_WEBXDC) { + dcContext.setDraft(dcChat.getId(), null); + } + + if(msg!=null) { + boolean doSend = true; + if (recompress==DcMsg.DC_MSG_VIDEO) { + Util.runOnMain(() -> { + if (isFinishing()) return; + progressDialog = ProgressDialog.show( + ConversationActivity.this, + "", + getString(R.string.one_moment), + true, + false + ); + }); + doSend = VideoRecoder.prepareVideo(ConversationActivity.this, dcChat.getId(), msg); + Util.runOnMain(() -> { + try { + if (progressDialog != null) progressDialog.dismiss(); + } catch (final IllegalArgumentException e) { + // The activity is finishing/destroyed, do nothing. + } + }); + } + + if (doSend) { + if (dcContext.sendMsg(dcChat.getId(), msg) == 0) { + String lastError = dcContext.getLastError(); + if (!"".equals(lastError)) { + Util.runOnMain(() -> Toast.makeText(ConversationActivity.this, lastError, Toast.LENGTH_LONG).show()); + } + future.set(chatId); + return; + } + } + + Util.runOnMain(() -> sendComplete(dcChat.getId())); + } + } else { + dcContext.setDraft(dcChat.getId(), msg); + } + future.set(chatId); + }); + + return future; + } + + + protected void sendComplete(int chatId) { + boolean refreshFragment = (chatId != this.chatId); + this.chatId = chatId; + + if (fragment == null || !fragment.isVisible() || isFinishing()) { + return; + } + + fragment.setLastSeen(-1); + + if (refreshFragment) { + fragment.reload(recipient, chatId); + DcHelper.getNotificationCenter(this).updateVisibleChat(dcContext.getAccountId(), chatId); + } + + fragment.scrollToBottom(); + attachmentManager.cleanup(); + } + + + // handle attachment drawer, camera, recorder + + private void updateToggleButtonState() { + if (inputPanel.isRecordingInLockedMode()) { + buttonToggle.display(sendButton); + quickAttachmentToggle.hide(); + return; + } + + if (!isEditing && composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) { + buttonToggle.display(attachButton); + quickAttachmentToggle.show(); + } else { + buttonToggle.display(sendButton); + quickAttachmentToggle.hide(); + } + } + + @Override + public void onRecorderPermissionRequired() { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_mic_denied)) + .execute(); + } + + @Override + public void onRecorderStarted() { + fragment.hideAddReactionView(); + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(20); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + audioRecorder.startRecording(); + } + + @Override + public void onRecorderLocked() { + updateToggleButtonState(); + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + + @Override + public void onRecorderFinished() { + updateToggleButtonState(); + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(20); + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + ListenableFuture> future = audioRecorder.stopRecording(); + future.addListener(new ListenableFuture.Listener>() { + @Override + public void onSuccess(final @NonNull Pair result) { + AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first, result.second, MediaUtil.AUDIO_AAC, true); + SlideDeck slideDeck = new SlideDeck(); + slideDeck.addSlide(audioSlide); + + processComposeControls(ACTION_SEND_OUT, "", slideDeck).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Integer chatId) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + PersistentBlobProvider.getInstance().delete(ConversationActivity.this, result.first); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + } + + @Override + public void onFailure(ExecutionException e) { + Toast.makeText(ConversationActivity.this, R.string.chat_unable_to_record_audio, Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public void onRecorderCanceled() { + updateToggleButtonState(); + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(50); + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + ListenableFuture> future = audioRecorder.stopRecording(); + future.addListener(new ListenableFuture.Listener>() { + @Override + public void onSuccess(final Pair result) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + PersistentBlobProvider.getInstance().delete(ConversationActivity.this, result.first); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @Override + public void onFailure(ExecutionException e) {} + }); + } + + private void reloadEmojiPicker() { + emojiPickerContainer.removeAllViews(); + emojiPicker = (MediaKeyboard) LayoutInflater.from(this).inflate(R.layout.conversation_activity_emojidrawer_stub, emojiPickerContainer, false); + emojiPickerContainer.addView(emojiPicker); + inputPanel.setMediaKeyboard(emojiPicker); + } + + @Override + public void onEmojiToggle() { + if (emojiPicker == null) { + reloadEmojiPicker(); + } + + if (container.getCurrentInput() == emojiPicker) { + container.showSoftkey(composeText); + } else { + container.show(composeText, emojiPicker); + } + } + + @Override + public void onQuoteDismissed() { + if (isEditing) composeText.setText(""); + isEditing = false; + } + + // media selected by the system keyboard + @Override + public void onMediaSelected(@NonNull Uri uri, String contentType) { + if (isEditing) return; + if (MediaUtil.isImageType(contentType)) { + sendSticker(uri, contentType); + } else if (MediaUtil.isVideoType(contentType)) { + setMedia(uri, MediaType.VIDEO); + } else if (MediaUtil.isAudioType(contentType)) { + setMedia(uri, MediaType.AUDIO); + } + } + + private void sendSticker(@NonNull Uri uri, String contentType) { + Attachment attachment = new UriAttachment(uri, null, contentType, + AttachmentDatabase.TRANSFER_PROGRESS_STARTED, 0, 0, 0, null, null, false); + String path = attachment.getRealPath(this); + + Optional quote = inputPanel.getQuote(); + inputPanel.clearQuote(); + + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_STICKER); + if (quote.isPresent()) { + msg.setQuote(quote.get().getQuotedMsg()); + } + msg.setFileAndDeduplicate(path, null, null); + msg.forceSticker(); + dcContext.sendMsg(chatId, msg); + } + + // Listeners + + private class AttachmentTypeListener implements AttachmentTypeSelector.AttachmentClickedListener { + @Override + public void onClick(int type) { + addAttachment(type); + } + + @Override + public void onQuickAttachment(Uri uri) { + Intent intent = new Intent(); + intent.setData(uri); + + onActivityResult(PICK_GALLERY, RESULT_OK, intent); + } + } + + private class SendButtonListener implements OnClickListener, TextView.OnEditorActionListener { + @Override + public void onClick(View v) { + if (inputPanel.isRecordingInLockedMode()) { + inputPanel.releaseRecordingLock(); + return; + } + + String rawText = composeText.getTextTrimmed(); + if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent()) { + Toast.makeText(ConversationActivity.this, R.string.chat_please_enter_message, + Toast.LENGTH_SHORT).show(); + } + else { + processComposeControls(ACTION_SEND_OUT).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Integer chatId) { + DcHelper.getNotificationCenter(ConversationActivity.this).maybePlaySendSound(dcChat); + } + }); + } + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + sendButton.performClick(); + return true; + } + return false; + } + } + + private class AttachButtonListener implements OnClickListener { + @Override + public void onClick(View v) { + fragment.hideAddReactionView(); + handleAddAttachment(); + } + } + + private class AttachButtonLongClickListener implements View.OnLongClickListener { + @Override + public boolean onLongClick(View v) { + return sendButton.performLongClick(); + } + } + + private class ComposeKeyPressedListener implements OnKeyListener, OnClickListener, TextWatcher, OnFocusChangeListener { + + int beforeLength; + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (Prefs.isEnterSendsEnabled(ConversationActivity.this)) { + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); + return true; + } + } + } + return false; + } + + @Override + public void onClick(View v) { + container.showSoftkey(composeText); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count,int after) { + beforeLength = composeText.getTextTrimmed().length(); + } + + @Override + public void afterTextChanged(Editable s) { + if (composeText.getTextTrimmed().length() == 0 || beforeLength == 0) { + composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50); + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before,int count) {} + + @Override + public void onFocusChange(View v, boolean hasFocus) {} + } + + @Override + public void handleReplyMessage(DcMsg msg) { + if (isEditing) composeText.setText(""); + isEditing = false; + // If you modify these lines you may also want to modify ConversationItem.setQuote(): + Recipient author = new Recipient(this, dcContext.getContact(msg.getFromId())); + + SlideDeck slideDeck = new SlideDeck(); + if (msg.hasFile()) { + slideDeck.addSlide(MediaUtil.getSlideForMsg(this, msg)); + } + + String text = msg.getSummarytext(500); + + inputPanel.setQuote(GlideApp.with(this), + msg, + msg.getTimestamp(), + author, + text, + slideDeck, + false); + + inputPanel.clickOnComposeInput(); + } + + @Override + public void handleEditMessage(DcMsg msg) { + isEditing = true; + Recipient author = new Recipient(this, dcContext.getContact(msg.getFromId())); + + SlideDeck slideDeck = new SlideDeck(); + String text = msg.getSummarytext(500); + + inputPanel.setQuote(GlideApp.with(this), + msg, + msg.getTimestamp(), + author, + text, + slideDeck, + true); + + setDraftText(msg.getText()); + inputPanel.clickOnComposeInput(); + } + + @Override + public void onAttachmentChanged() { + handleSecurityChange(isSecureText, isDefaultSms); + updateToggleButtonState(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + if ((eventId == DcContext.DC_EVENT_CHAT_MODIFIED && event.getData1Int() == chatId) + || (eventId == DcContext.DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED && event.getData1Int() == chatId) + || eventId == DcContext.DC_EVENT_CONTACTS_CHANGED) { + dcChat = dcContext.getChat(chatId); + titleView.setTitle(glideRequests, dcChat); + initializeSecurity(isSecureText, isDefaultSms); + setComposePanelVisibility(); + initializeContactRequest(); + } else if ((eventId == DcContext.DC_EVENT_INCOMING_MSG + || eventId == DcContext.DC_EVENT_MSG_READ) + && event.getData1Int() == chatId) { + dcChat = dcContext.getChat(chatId); + titleView.setTitle(glideRequests, dcChat); + } + } + + + // in-chat search + + private int beforeSearchComposeVisibility = View.VISIBLE; + + private Menu searchMenu = null; + private int[] searchResult = {}; + private int searchResultPosition = -1; + + private Toast lastToast = null; + + private void updateResultCounter(int curr, int total) { + if (searchMenu!=null) { + MenuItem item = searchMenu.findItem(R.id.menu_search_counter); + if (curr!=-1) { + item.setTitle(String.format("%d/%d", total==0? 0 : curr+1, total)); + item.setVisible(true); + } else { + item.setVisible(false); + } + } + } + + private void searchExpand(final Menu menu, final MenuItem searchItem) { + searchMenu = menu; + + beforeSearchComposeVisibility = composePanel.getVisibility(); + composePanel.setVisibility(View.GONE); + + ConversationActivity.this.makeSearchMenuVisible(menu, searchItem, true); + } + + private void searchCollapse(final Menu menu, final MenuItem searchItem) { + searchMenu = null; + composePanel.setVisibility(beforeSearchComposeVisibility); + + ConversationActivity.this.makeSearchMenuVisible(menu, searchItem, false); + } + + private void handleMenuSearchNext(boolean searchNext) { + if(searchResult.length>0) { + searchResultPosition += searchNext? 1 : -1; + if(searchResultPosition<0) searchResultPosition = searchResult.length-1; + if(searchResultPosition>=searchResult.length) searchResultPosition = 0; + fragment.scrollToMsgId(searchResult[searchResultPosition]); + updateResultCounter(searchResultPosition, searchResult.length); + } else { + // no search, scroll to first/last message + if(searchNext) { + fragment.scrollToBottom(); + } else { + fragment.scrollToTop(); + } + } + } + + @Override + public boolean onQueryTextSubmit(String query) { + return true; // action handled by listener + } + + @Override + public boolean onQueryTextChange(String query) { + if (lastToast!=null) { + lastToast.cancel(); + lastToast = null; + } + + String normQuery = query.trim(); + searchResult = dcContext.searchMsgs(chatId, normQuery); + + if(searchResult.length>0) { + searchResultPosition = searchResult.length - 1; + fragment.scrollToMsgId(searchResult[searchResultPosition]); + updateResultCounter(searchResultPosition, searchResult.length); + } else { + searchResultPosition = -1; + if (normQuery.isEmpty()) { + updateResultCounter(-1, 0); // hide + } else { + String msg = getString(R.string.search_no_result_for_x, normQuery); + if (lastToast != null) { + lastToast.cancel(); + } + lastToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT); + lastToast.show(); + updateResultCounter(0, 0); // show as "0/0" + } + } + return true; // action handled by listener + } + + public void initializeContactRequest() { + if (!dcChat.isContactRequest()) { + messageRequestBottomView.setVisibility(View.GONE); + return; + } + + messageRequestBottomView.setVisibility(View.VISIBLE); + messageRequestBottomView.setAcceptOnClickListener(v -> { + dcContext.acceptChat(chatId); + messageRequestBottomView.setVisibility(View.GONE); + composePanel.setVisibility(View.VISIBLE); + }); + + + if (dcChat.getType() == DcChat.DC_CHAT_TYPE_GROUP) { + // We don't support blocking groups yet, so offer to delete it instead + messageRequestBottomView.setBlockText(R.string.delete); + messageRequestBottomView.setBlockOnClickListener(v -> handleDeleteChat()); + messageRequestBottomView.setQuestion(null); + + } else { + messageRequestBottomView.setBlockText(R.string.block); + messageRequestBottomView.setBlockOnClickListener(v -> { + // avoid showing compose panel on receiving DC_EVENT_CONTACTS_CHANGED for the chat that is no longer a request after blocking + DcHelper.getEventCenter(this).removeObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + + dcContext.blockChat(chatId); + Bundle extras = new Bundle(); + extras.putInt(ConversationListFragment.RELOAD_LIST, 1); + handleReturnToConversationList(extras); + }); + messageRequestBottomView.setQuestion(null); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..10d30f9b06764d8a2b98aae632dc6de1797782fc --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java @@ -0,0 +1,449 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.ConversationItem.PULSE_HIGHLIGHT_MILLIS; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.ref.SoftReference; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * A DC adapter for a conversation thread. Ultimately + * used by ConversationActivity to display a conversation + * thread in a ListActivity. + * + * @author Moxie Marlinspike + * + */ +public class ConversationAdapter + extends RecyclerView.Adapter + implements StickyHeaderDecoration.StickyHeaderAdapter +{ + + private static final int MAX_CACHE_SIZE = 40; + private final Map> recordCache = + Collections.synchronizedMap(new LRUCache>(MAX_CACHE_SIZE)); + + private static final int MESSAGE_TYPE_OUTGOING = 0; + private static final int MESSAGE_TYPE_INCOMING = 1; + private static final int MESSAGE_TYPE_INFO = 2; + private static final int MESSAGE_TYPE_AUDIO_OUTGOING = 3; + private static final int MESSAGE_TYPE_AUDIO_INCOMING = 4; + private static final int MESSAGE_TYPE_THUMBNAIL_OUTGOING = 5; + private static final int MESSAGE_TYPE_THUMBNAIL_INCOMING = 6; + private static final int MESSAGE_TYPE_DOCUMENT_OUTGOING = 7; + private static final int MESSAGE_TYPE_DOCUMENT_INCOMING = 8; + private static final int MESSAGE_TYPE_STICKER_INCOMING = 9; + private static final int MESSAGE_TYPE_STICKER_OUTGOING = 10; + + private final Set batchSelected = Collections.synchronizedSet(new HashSet()); + + private final @Nullable ItemClickListener clickListener; + private final @NonNull GlideRequests glideRequests; + private final @NonNull Recipient recipient; + private final @NonNull LayoutInflater inflater; + private final @NonNull Context context; + private final @NonNull Calendar calendar; + + private final DcContext dcContext; + private @NonNull DcChat dcChat; + private @NonNull int[] dcMsgList = new int[0]; + private int positionToPulseHighlight = -1; + private int positionCurrentlyPulseHighlighting = -1; + private long pulseHighlightingSince = -1; + private int lastSeenPosition = -1; + private long lastSeen = -1; + + protected static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(final @NonNull V itemView) { + super(itemView); + } + + @SuppressWarnings("unchecked") + public V getView() { + return (V)itemView; + } + + public BindableConversationItem getItem() { + return getView(); + } + } + + + public boolean isActive() { + return dcMsgList.length > 0; + } + + public @NonNull DcChat getChat(){ + return dcChat; + } + + + public void setLastSeen(long timestamp) { + lastSeen = timestamp; + } + + public void updateLastSeenPosition() { + this.lastSeenPosition = findLastSeenPosition(lastSeen); + } + + void setLastSeenPosition(int pos) { + lastSeenPosition = pos; + } + + public int getLastSeenPosition() { + return lastSeenPosition; + } + + @Override + public int getItemCount() { + return dcMsgList.length; + } + + @Override + public long getItemId(int position) { + if (position<0 || position>=dcMsgList.length) { + return 0; + } + return dcMsgList[dcMsgList.length-1-position]; + } + + public @NonNull DcMsg getMsg(int position) { + if(position<0 || position>=dcMsgList.length) { + return new DcMsg(0); + } + + final SoftReference reference = recordCache.get(position); + if (reference != null) { + final DcMsg fromCache = reference.get(); + if (fromCache != null) { + return fromCache; + } + } + + final DcMsg fromDb = dcContext.getMsg((int)getItemId(position)); + recordCache.put(position, new SoftReference<>(fromDb)); + return fromDb; + } + + /** + * Returns the position of the message with msgId in the chat list, counted from the top + */ + public int msgIdToPosition(int msgId) { + for(int i=0; i { + if (clickListener != null) { + clickListener.onItemClick(itemView.getMessageRecord()); + } + }); + itemView.setOnLongClickListener(view -> { + if (clickListener != null) { + clickListener.onItemLongClick(itemView.getMessageRecord(), view); + } + return true; + }); + itemView.setEventListener(clickListener); + return new ViewHolder(itemView); + } + + private @LayoutRes int getLayoutForViewType(int viewType) { + switch (viewType) { + case MESSAGE_TYPE_AUDIO_OUTGOING: + case MESSAGE_TYPE_THUMBNAIL_OUTGOING: + case MESSAGE_TYPE_DOCUMENT_OUTGOING: + case MESSAGE_TYPE_STICKER_OUTGOING: + case MESSAGE_TYPE_OUTGOING: return R.layout.conversation_item_sent; + case MESSAGE_TYPE_AUDIO_INCOMING: + case MESSAGE_TYPE_THUMBNAIL_INCOMING: + case MESSAGE_TYPE_DOCUMENT_INCOMING: + case MESSAGE_TYPE_STICKER_INCOMING: + case MESSAGE_TYPE_INCOMING: return R.layout.conversation_item_received; + case MESSAGE_TYPE_INFO: return R.layout.conversation_item_update; + default: throw new IllegalArgumentException("unsupported item view type given to ConversationAdapter"); + } + } + + @Override + public int getItemViewType(int i) { + DcMsg dcMsg = getMsg(i); + int type = dcMsg.getType(); + if (dcMsg.isInfo()) { + return MESSAGE_TYPE_INFO; + } + else if (type==DcMsg.DC_MSG_AUDIO || type==DcMsg.DC_MSG_VOICE) { + return dcMsg.isOutgoing()? MESSAGE_TYPE_AUDIO_OUTGOING : MESSAGE_TYPE_AUDIO_INCOMING; + } + else if (type==DcMsg.DC_MSG_FILE) { + return dcMsg.isOutgoing()? MESSAGE_TYPE_DOCUMENT_OUTGOING : MESSAGE_TYPE_DOCUMENT_INCOMING; + } + else if (type==DcMsg.DC_MSG_IMAGE || type==DcMsg.DC_MSG_GIF || type==DcMsg.DC_MSG_VIDEO) { + return dcMsg.isOutgoing()? MESSAGE_TYPE_THUMBNAIL_OUTGOING : MESSAGE_TYPE_THUMBNAIL_INCOMING; + } + else if (type == DcMsg.DC_MSG_STICKER) { + return dcMsg.isOutgoing()? MESSAGE_TYPE_STICKER_OUTGOING : MESSAGE_TYPE_STICKER_INCOMING; + } + else { + return dcMsg.isOutgoing()? MESSAGE_TYPE_OUTGOING : MESSAGE_TYPE_INCOMING; + } + } + + public void toggleSelection(DcMsg messageRecord) { + if (!batchSelected.remove(messageRecord)) { + batchSelected.add(messageRecord); + } + } + + public void clearSelection() { + batchSelected.clear(); + } + + public Set getSelectedItems() { + return Collections.unmodifiableSet(new HashSet<>(batchSelected)); + } + + public int[] getMessageIds() { + return dcMsgList; + } + + public void pulseHighlightItem(int position) { + if (position>=0 && position < getItemCount()) { + positionToPulseHighlight = position; + notifyItemChanged(position); + } + } + + public long getSortTimestamp(int position) { + if (!isActive()) return 0; + if (position >= getItemCount()) return 0; + if (position < 0) return 0; + + DcMsg msg = getMsg(position); + return msg.getSortTimestamp(); + } + + @NonNull + public Context getContext() { + return context; + } + + @Override + public long getHeaderId(int position) { + if (position >= getItemCount()) return -1; + if (position < 0) return -1; + + calendar.setTime(new Date(getSortTimestamp(position))); + return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR)); + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_header, parent, false)); + } + + /** + * date header view + */ + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { + viewHolder.setText(DateUtils.getRelativeDate(getContext(), getSortTimestamp(position))); + } + + + public void changeData(@Nullable int[] dcMsgList) { + // should be called when there are new messages + this.dcMsgList = dcMsgList == null ? new int[0] : dcMsgList; + reloadData(); + } + + public void reloadChat() { + // should be called when the chat was modified + dcChat = dcContext.getChat(dcChat.getId()); + } + + private void reloadData() { + // should be called when some items in a message are changed, eg. seen-state + recordCache.clear(); + updateLastSeenPosition(); + notifyDataSetChanged(); + } + + private int findLastSeenPosition(long lastSeen) { + if (lastSeen <= 0) return -1; + if (!isActive()) return -1; + + int count = getItemCount(); + + + for (int i = 0; i < count; i++) { + DcMsg msg = getMsg(i); + if (msg.isOutgoing() || msg.getTimestamp() <= lastSeen) { + return i - 1; + } + } + + return -1; + } + + public HeaderViewHolder onCreateLastSeenViewHolder(ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_last_seen, parent, false)); + } + + public void onBindLastSeenViewHolder(HeaderViewHolder viewHolder, int position) { + viewHolder.setText(getContext().getResources().getQuantityString(R.plurals.chat_n_new_messages, (position + 1), (position + 1))); + } + + static class LastSeenHeader extends StickyHeaderDecoration { + private final ConversationAdapter adapter; + + LastSeenHeader(ConversationAdapter adapter) { + super(adapter, false, false); + this.adapter = adapter; + } + + @Override + protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) { + return adapter.isActive() && position == adapter.getLastSeenPosition(); + } + + @Override + protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) { + return parent.getLayoutManager().getDecoratedTop(child); + } + + @Override + protected HeaderViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) { + HeaderViewHolder viewHolder = adapter.onCreateLastSeenViewHolder(parent); + adapter.onBindLastSeenViewHolder(viewHolder, position); + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), viewHolder.itemView.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), viewHolder.itemView.getLayoutParams().height); + + viewHolder.itemView.measure(childWidth, childHeight); + viewHolder.itemView.layout(0, 0, viewHolder.itemView.getMeasuredWidth(), viewHolder.itemView.getMeasuredHeight()); + + return viewHolder; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..3bf32b6328928e1bbdeca2d5eb3cced7cfa5bbfd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -0,0 +1,994 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import static com.b44t.messenger.DcContact.DC_CONTACT_ID_SELF; +import static org.thoughtcrime.securesms.util.ShareUtil.setForwardingMessageIds; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnScrollListener; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener; +import org.thoughtcrime.securesms.components.reminder.DozeReminder; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.reactions.AddReactionView; +import org.thoughtcrime.securesms.reactions.ReactionsDetailsFragment; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.relay.EditRelayActivity; +import org.thoughtcrime.securesms.util.AccessibilityUtil; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.ConversationAdaptiveActionsToolbar; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; + +@SuppressLint("StaticFieldLeak") +public class ConversationFragment extends MessageSelectorFragment +{ + private static final String TAG = ConversationFragment.class.getSimpleName(); + + private static final int SCROLL_ANIMATION_THRESHOLD = 50; + private static final int CODE_ADD_EDIT_CONTACT = 77; + + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener(); + + private ConversationFragmentListener listener; + + private Recipient recipient; + private long chatId; + private int startingPosition; + private boolean firstLoad; + private RecyclerView list; + private RecyclerView.ItemDecoration lastSeenDecoration; + private StickyHeaderDecoration dateDecoration; + private View scrollToBottomButton; + private View floatingLocationButton; + private AddReactionView addReactionView; + private TextView noMessageTextView; + private Timer reloadTimer; + + public boolean isPaused; + private Debouncer markseenDebouncer; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + this.dcContext = DcHelper.getContext(getContext()); + + DcEventCenter eventCenter = DcHelper.getEventCenter(getContext()); + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_REACTIONS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_DELIVERED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_FAILED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + + markseenDebouncer = new Debouncer(800); + reloadTimer = new Timer("reloadTimer", false); + reloadTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + Util.runOnMain(ConversationFragment.this::reloadList); + } + }, 60 * 1000, 60 * 1000); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { + final View view = inflater.inflate(R.layout.conversation_fragment, container, false); + list = ViewUtil.findById(view, android.R.id.list); + scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button); + floatingLocationButton = ViewUtil.findById(view, R.id.floating_location_button); + addReactionView = ViewUtil.findById(view, R.id.add_reaction_view); + noMessageTextView = ViewUtil.findById(view, R.id.no_messages_text_view); + + scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); + + final SetStartingPositionLinearLayoutManager layoutManager = new SetStartingPositionLinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, true); + + list.setHasFixedSize(false); + list.setLayoutManager(layoutManager); + list.setItemAnimator(null); + + new ConversationItemSwipeCallback( + msg -> actionMode == null, + this::handleReplyMessage + ).attachToRecyclerView(list); + + // setLayerType() is needed to allow larger items (long texts in our case) + // with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be rendered into a texture" + list.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + + return view; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + initializeResources(); + initializeListAdapter(); + } + + private void setNoMessageText() { + DcChat dcChat = getListAdapter().getChat(); + if(dcChat.isMultiUser()){ + if (dcChat.isInBroadcast() || dcChat.isOutBroadcast()) { + noMessageTextView.setText(R.string.chat_new_channel_hint); + } else if (dcChat.isUnpromoted()) { + noMessageTextView.setText(R.string.chat_new_group_hint); + } + else { + noMessageTextView.setText(R.string.chat_no_messages); + } + } + else if(dcChat.isSelfTalk()) { + noMessageTextView.setText(R.string.saved_messages_explain); + } + else if(dcChat.isDeviceTalk()) { + noMessageTextView.setText(R.string.device_talk_explain); + } + else if(!dcChat.isEncrypted()) { + noMessageTextView.setText(R.string.chat_unencrypted_explanation); + } + else { + String message = getString(R.string.chat_new_one_to_one_hint, dcChat.getName()); + noMessageTextView.setText(message); + } + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(getContext()).removeObservers(this); + reloadTimer.cancel(); + super.onDestroy(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.listener = (ConversationFragmentListener)activity; + } + + @Override + public void onResume() { + super.onResume(); + + Util.runOnBackground(() -> dcContext.marknoticedChat((int) chatId)); + if (list.getAdapter() != null) { + list.getAdapter().notifyDataSetChanged(); + } + + if (isPaused) { + isPaused = false; + markseenDebouncer.publish(() -> manageMessageSeenState()); + } + } + + + @Override + public void onPause() { + super.onPause(); + setLastSeen(System.currentTimeMillis()); + isPaused = true; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + dateDecoration.onConfigurationChanged(newConfig); + } + + public void onNewIntent() { + if (actionMode != null) { + actionMode.finish(); + } + + initializeResources(); + initializeListAdapter(); + + if (chatId == -1) { + reloadList(); + updateLocationButton(); + } + } + + public void moveToLastSeen() { + if (list == null || getListAdapter() == null) { + Log.w(TAG, "Tried to move to last seen position, but we hadn't initialized the view yet."); + return; + } + + if (getListAdapter().getLastSeenPosition() < 0) { + return; + } + final int lastSeenPosition = getListAdapter().getLastSeenPosition() + 1; + if (lastSeenPosition > 0) { + list.post(() -> ((LinearLayoutManager)list.getLayoutManager()).scrollToPositionWithOffset(lastSeenPosition, list.getHeight())); + } + } + + public void hideAddReactionView() { + addReactionView.hide(); + } + + private void initializeResources() { + this.chatId = this.getActivity().getIntent().getIntExtra(ConversationActivity.CHAT_ID_EXTRA, -1); + this.recipient = Recipient.from(getActivity(), Address.fromChat((int)this.chatId)); + this.startingPosition = this.getActivity().getIntent().getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1); + this.firstLoad = true; + + OnScrollListener scrollListener = new ConversationScrollListener(getActivity()); + list.addOnScrollListener(scrollListener); + } + + private void initializeListAdapter() { + if (this.recipient != null && this.chatId != -1) { + ConversationAdapter adapter = new ConversationAdapter(getActivity(), this.recipient.getChat(), GlideApp.with(this), selectionClickListener, this.recipient); + list.setAdapter(adapter); + + if (dateDecoration != null) { + list.removeItemDecoration(dateDecoration); + } + dateDecoration = new StickyHeaderDecoration(adapter, false, false); + list.addItemDecoration(dateDecoration); + + int freshMsgs = dcContext.getFreshMsgCount((int) chatId); + SetStartingPositionLinearLayoutManager layoutManager = (SetStartingPositionLinearLayoutManager) list.getLayoutManager(); + if (startingPosition > -1) { + layoutManager.setStartingPosition(startingPosition); + } else if (freshMsgs > 0) { + layoutManager.setStartingPosition(freshMsgs - 1); + } + + reloadList(); + updateLocationButton(); + + if (lastSeenDecoration != null) { + list.removeItemDecoration(lastSeenDecoration); + } + if (freshMsgs > 0) { + getListAdapter().setLastSeenPosition(freshMsgs - 1); + lastSeenDecoration = new ConversationAdapter.LastSeenHeader(getListAdapter()); + list.addItemDecoration(lastSeenDecoration); + } + } + } + + @Override + protected void setCorrectMenuVisibility(Menu menu) { + Set messageRecords = getListAdapter().getSelectedItems(); + + if (actionMode != null && messageRecords.size() == 0) { + actionMode.finish(); + return; + } + + menu.findItem(R.id.menu_toggle_save).setVisible(false); + + if (messageRecords.size() > 1) { + menu.findItem(R.id.menu_context_details).setVisible(false); + menu.findItem(R.id.menu_context_share).setVisible(false); + menu.findItem(R.id.menu_context_reply).setVisible(false); + menu.findItem(R.id.menu_context_edit).setVisible(false); + menu.findItem(R.id.menu_context_reply_privately).setVisible(false); + menu.findItem(R.id.menu_add_to_home_screen).setVisible(false); + //menu.findItem(R.id.menu_toggle_save).setVisible(false); + } else { + DcMsg messageRecord = messageRecords.iterator().next(); + DcChat chat = getListAdapter().getChat(); + menu.findItem(R.id.menu_context_details).setVisible(true); + menu.findItem(R.id.menu_context_share).setVisible(messageRecord.hasFile()); + boolean canReply = canReplyToMsg(messageRecord); + menu.findItem(R.id.menu_context_reply).setVisible(chat.canSend() && canReply); + menu.findItem(R.id.menu_context_edit).setVisible(chat.isEncrypted() && chat.canSend() && canEditMsg(messageRecord)); + boolean showReplyPrivately = chat.isMultiUser() && !messageRecord.isOutgoing() && canReply; + menu.findItem(R.id.menu_context_reply_privately).setVisible(showReplyPrivately); + menu.findItem(R.id.menu_add_to_home_screen).setVisible(messageRecord.getType() == DcMsg.DC_MSG_WEBXDC); + + /* + boolean saved = messageRecord.getSavedMsgId() != 0; + MenuItem toggleSave = menu.findItem(R.id.menu_toggle_save); + toggleSave.setVisible(messageRecord.canSave() && !chat.isSelfTalk()); + toggleSave.setIcon(saved? R.drawable.baseline_bookmark_remove_24 : R.drawable.baseline_bookmark_border_24); + toggleSave.setTitle(saved? R.string.unsave : R.string.save); + */ + } + + // if one of the selected items cannot be saved, disable saving. + boolean canSave = true; + // if one of the selected items is not from self, disable resending. + boolean canResend = true; + for (DcMsg messageRecord : messageRecords) { + if (canSave && !messageRecord.hasFile()) { + canSave = false; + } + if (canResend && !messageRecord.isOutgoing()) { + canResend = false; + } + if (!canSave && !canResend) { + break; + } + } + menu.findItem(R.id.menu_context_save_attachment).setVisible(canSave); + menu.findItem(R.id.menu_resend).setVisible(canResend); + } + + static boolean canReplyToMsg(DcMsg dcMsg) { + if (dcMsg.isInfo()) { + switch (dcMsg.getInfoType()) { + case DcMsg.DC_INFO_GROUP_NAME_CHANGED: + case DcMsg.DC_INFO_GROUP_IMAGE_CHANGED: + case DcMsg.DC_INFO_MEMBER_ADDED_TO_GROUP: + case DcMsg.DC_INFO_MEMBER_REMOVED_FROM_GROUP: + case DcMsg.DC_INFO_LOCATIONSTREAMING_ENABLED: + case DcMsg.DC_INFO_EPHEMERAL_TIMER_CHANGED: + case DcMsg.DC_INFO_WEBXDC_INFO_MESSAGE: + return true; + default: + return false; + } + } + return true; + } + + static boolean canEditMsg(DcMsg dcMsg) { + return dcMsg.isOutgoing() && !dcMsg.isInfo() && dcMsg.getType() != DcMsg.DC_MSG_CALL && !dcMsg.hasHtml() && !dcMsg.getText().isEmpty(); + } + + public void handleClearChat() { + handleDeleteMessages((int) chatId, getListAdapter().getMessageIds()); + } + + private ConversationAdapter getListAdapter() { + return (ConversationAdapter) list.getAdapter(); + } + + public void reload(Recipient recipient, long chatId) { + this.recipient = recipient; + + if (this.chatId != chatId) { + this.chatId = chatId; + initializeListAdapter(); + } + } + + public void scrollToTop() { + ConversationAdapter adapter = (ConversationAdapter)list.getAdapter(); + if (adapter.getItemCount()>0) { + final int pos = adapter.getItemCount()-1; + list.post(() -> { + list.getLayoutManager().scrollToPosition(pos); + }); + } + } + + public void scrollToBottom() { + if (((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition() < SCROLL_ANIMATION_THRESHOLD + && !AccessibilityUtil.areAnimationsDisabled(getContext())) { + list.smoothScrollToPosition(0); + } else { + list.scrollToPosition(0); + } + } + + void setLastSeen(long lastSeen) { + ConversationAdapter adapter = getListAdapter(); + if (adapter != null) { + adapter.setLastSeen(lastSeen); + if (lastSeenDecoration != null) { + list.removeItemDecoration(lastSeenDecoration); + } + if (lastSeen > 0) { + lastSeenDecoration = new ConversationAdapter.LastSeenHeader(adapter); + list.addItemDecoration(lastSeenDecoration); + } + } + } + + private void handleCopyMessage(final Set dcMsgsSet) { + List dcMsgsList = new LinkedList<>(dcMsgsSet); + Collections.sort(dcMsgsList, (lhs, rhs) -> Long.compare(lhs.getDateReceived(), rhs.getDateReceived())); + boolean singleMsg = dcMsgsList.size() == 1; + + StringBuilder result = new StringBuilder(); + + DcMsg prevMsg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + for (DcMsg msg : dcMsgsList) { + if (result.length() > 0) { + result.append("\n\n"); + } + + if (msg.getFromId() != prevMsg.getFromId() && !singleMsg) { + DcContact contact = dcContext.getContact(msg.getFromId()); + result.append(msg.getSenderName(contact)).append(":\n"); + } + if (msg.getType() == DcMsg.DC_MSG_TEXT || (singleMsg && !msg.getText().isEmpty())) { + result.append(msg.getText()); + } else { + result.append(msg.getSummarytext(10000000)); + } + + prevMsg = msg; + } + + if (result.length() > 0) { + Util.writeTextToClipboard(getActivity(), result.toString()); + Toast.makeText(getActivity(), getActivity().getResources().getString(R.string.copied_to_clipboard), Toast.LENGTH_LONG).show(); + } + } + + private void handleForwardMessage(final Set messageRecords) { + Intent composeIntent = new Intent(); + int[] msgIds = DcMsg.msgSetToIds(messageRecords); + setForwardingMessageIds(composeIntent, msgIds); + ConversationListRelayingActivity.start(this, composeIntent); + getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out); + } + + @SuppressLint("RestrictedApi") + private void handleReplyMessage(final DcMsg message) { + if (getActivity() != null) { + //noinspection ConstantConditions + ((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView(); + } + + listener.handleReplyMessage(message); + } + + @SuppressLint("RestrictedApi") + private void handleEditMessage(final DcMsg message) { + if (getActivity() != null) { + //noinspection ConstantConditions + ((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView(); + } + + listener.handleEditMessage(message); + } + + private void handleReplyMessagePrivately(final DcMsg msg) { + + if (getActivity() != null) { + int privateChatId = dcContext.createChatByContactId(msg.getFromId()); + DcMsg replyMsg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + replyMsg.setQuote(msg); + dcContext.setDraft(privateChatId, replyMsg); + + Intent intent = new Intent(getActivity(), ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, privateChatId); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + getActivity().startActivity(intent); + } else { + Log.e(TAG, "Activity was null"); + } + } + + private void handleToggleSave(final Set messageRecords) { + DcMsg msg = getSelectedMessageRecord(messageRecords); + if (msg.getSavedMsgId() != 0) { + dcContext.deleteMsgs(new int[]{msg.getSavedMsgId()}); + } else { + dcContext.saveMsgs(new int[]{msg.getId()}); + } + } + + private void reloadList() { + reloadList(false); + } + + private void reloadList(boolean chatModified) { + ConversationAdapter adapter = getListAdapter(); + if (adapter == null) { + return; + } + + // if chat is a contact request and is accepted/blocked, the DcChat object must be reloaded, otherwise DcChat.canSend() returns wrong values + if (chatModified) { + adapter.reloadChat(); + } + + int oldCount = 0; + int oldIndex = 0; + int pixelOffset = 0; + if (!firstLoad) { + oldCount = adapter.getItemCount(); + oldIndex = ((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition(); + View firstView = list.getLayoutManager().findViewByPosition(oldIndex); + pixelOffset = (firstView == null) ? 0 : list.getBottom() - firstView.getBottom() - list.getPaddingBottom(); + } + + if (getContext() == null) { + Log.e(TAG, "reloadList: getContext() was null"); + return; + } + + DcContext dcContext = DcHelper.getContext(getContext()); + + long startMs = System.currentTimeMillis(); + int[] msgs = dcContext.getChatMsgs((int) chatId, 0, 0); + Log.i(TAG, "⏰ getChatMsgs(" + chatId + "): " + (System.currentTimeMillis() - startMs) + "ms"); + + adapter.changeData(msgs); + + if (firstLoad) { + if (startingPosition >= 0) { + getListAdapter().pulseHighlightItem(startingPosition); + } + firstLoad = false; + } else if(oldIndex > 0) { + int newIndex = oldIndex + msgs.length - oldCount; + + if (newIndex < 0) { newIndex = 0; pixelOffset = 0; } + else if (newIndex >= msgs.length) { newIndex = msgs.length - 1; pixelOffset = 0; } + + ((LinearLayoutManager) list.getLayoutManager()).scrollToPositionWithOffset(newIndex, pixelOffset); + } + + if(!adapter.isActive()){ + setNoMessageText(); + noMessageTextView.setVisibility(View.VISIBLE); + } + else{ + noMessageTextView.setVisibility(View.GONE); + } + + if (!isPaused) { + markseenDebouncer.publish(() -> manageMessageSeenState()); + } + } + + private void updateLocationButton() { + floatingLocationButton.setVisibility(dcContext.isSendingLocationsToChat((int) chatId)? View.VISIBLE : View.GONE); + } + + private void scrollAndHighlight(final int pos, boolean smooth) { + list.post(() -> { + if (smooth && !AccessibilityUtil.areAnimationsDisabled(getContext())) { + list.smoothScrollToPosition(pos); + } else { + list.scrollToPosition(pos); + } + getListAdapter().pulseHighlightItem(pos); + }); + } + + public void scrollToMsgId(final int msgId) { + ConversationAdapter adapter = (ConversationAdapter)list.getAdapter(); + int position = adapter.msgIdToPosition(msgId); + if (position!=-1) { + scrollAndHighlight(position, false); + } else { + Log.e(TAG, "msgId {} not found for scrolling"); + } + } + + private void scrollMaybeSmoothToMsgId(final int msgId) { + LinearLayoutManager layout = ((LinearLayoutManager) list.getLayoutManager()); + boolean smooth = false; + ConversationAdapter adapter = (ConversationAdapter) list.getAdapter(); + if (adapter == null) return; + int position = adapter.msgIdToPosition(msgId); + if (layout != null) { + int distance1 = Math.abs(position - layout.findFirstVisibleItemPosition()); + int distance2 = Math.abs(position - layout.findLastVisibleItemPosition()); + int distance = Math.min(distance1, distance2); + smooth = distance < 15; + Log.i(TAG, "Scrolling to destMsg, smoth: " + smooth + ", distance: " + distance); + } + + if (position != -1) { + scrollAndHighlight(position, smooth); + } else { + Log.e(TAG, "msgId not found for scrolling: " + msgId); + } + } + + public interface ConversationFragmentListener { + void handleReplyMessage(DcMsg messageRecord); + void handleEditMessage(DcMsg messageRecord); + } + + private class ConversationScrollListener extends OnScrollListener { + + private final Animation scrollButtonInAnimation; + private final Animation scrollButtonOutAnimation; + + private boolean wasAtBottom = true; + private boolean wasAtZoomScrollHeight = false; + //private long lastPositionId = -1; + + ConversationScrollListener(@NonNull Context context) { + this.scrollButtonInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in); + this.scrollButtonOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out); + + this.scrollButtonInAnimation.setDuration(100); + this.scrollButtonOutAnimation.setDuration(50); + } + + @Override + public void onScrolled(final RecyclerView rv, final int dx, final int dy) { + boolean currentlyAtBottom = isAtBottom(); + boolean currentlyAtZoomScrollHeight = isAtZoomScrollHeight(); +// int positionId = getHeaderPositionId(); + + if (currentlyAtZoomScrollHeight && !wasAtZoomScrollHeight) { + ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation); + } else if (currentlyAtBottom && !wasAtBottom) { + ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE); + } + +// if (positionId != lastPositionId) { +// bindScrollHeader(conversationDateHeader, positionId); +// } + + wasAtBottom = currentlyAtBottom; + wasAtZoomScrollHeight = currentlyAtZoomScrollHeight; +// lastPositionId = positionId; + + markseenDebouncer.publish(() -> manageMessageSeenState()); + + ConversationFragment.this.addReactionView.move(dy); + } + + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { +// if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { +// conversationDateHeader.show(); +// } else if (newState == RecyclerView.SCROLL_STATE_IDLE) { +// conversationDateHeader.hide(); +// } + } + + private boolean isAtBottom() { + if (list.getChildCount() == 0) return true; + + View bottomView = list.getChildAt(0); + int firstVisibleItem = ((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition(); + boolean isAtBottom = (firstVisibleItem == 0); + + return isAtBottom && bottomView.getBottom() <= list.getHeight(); + } + + private boolean isAtZoomScrollHeight() { + return ((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition() > 4; + } + + // private int getHeaderPositionId() { + // return ((LinearLayoutManager)list.getLayoutManager()).findLastVisibleItemPosition(); + // } + + // private void bindScrollHeader(HeaderViewHolder headerViewHolder, int positionId) { + // if (((ConversationAdapter)list.getAdapter()).getHeaderId(positionId) != -1) { + // ((ConversationAdapter) list.getAdapter()).onBindHeaderViewHolder(headerViewHolder, positionId); + // } + // } + } + + private void manageMessageSeenState() { + + LinearLayoutManager layoutManager = (LinearLayoutManager)list.getLayoutManager(); + + int firstPos = layoutManager.findFirstVisibleItemPosition(); + int lastPos = layoutManager.findLastVisibleItemPosition(); + if(firstPos == RecyclerView.NO_POSITION || lastPos == RecyclerView.NO_POSITION) { + return; + } + + int[] ids = new int[lastPos - firstPos + 1]; + int index = 0; + for(int pos = firstPos; pos <= lastPos; pos++) { + DcMsg message = ((ConversationAdapter) list.getAdapter()).getMsg(pos); + if (message.getFromId() != DC_CONTACT_ID_SELF) { + ids[index] = message.getId(); + index++; + } + } + Util.runOnAnyBackgroundThread(() -> dcContext.markseenMsgs(ids)); + } + + private class ConversationFragmentItemClickListener implements ItemClickListener { + + @Override + public void onItemClick(DcMsg messageRecord) { + if (actionMode != null) { + ((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord); + list.getAdapter().notifyDataSetChanged(); + + if (getListAdapter().getSelectedItems().size() == 0) { + actionMode.finish(); + } else { + hideAddReactionView(); + Menu menu = actionMode.getMenu(); + setCorrectMenuVisibility(menu); + ConversationAdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth()); + actionMode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size())); + actionMode.setTitleOptionalHint(false); // the title represents important information, also indicating implicitly, more items can be selected + } + } + else if(DozeReminder.isDozeReminderMsg(getContext(), messageRecord)) { + DozeReminder.dozeReminderTapped(getContext()); + } + else if(messageRecord.getInfoType() == DcMsg.DC_INFO_WEBXDC_INFO_MESSAGE) { + if (messageRecord.getParent() != null) { + // if the parent webxdc message still exists + WebxdcActivity.openWebxdcActivity(getContext(), messageRecord.getParent(), messageRecord.getWebxdcHref()); + } + } + else if (!TextUtils.isEmpty(messageRecord.getPOILocation()) && messageRecord.getType() == DcMsg.DC_MSG_TEXT && !messageRecord.hasHtml()) { + WebxdcActivity.openMaps(getContext(), getListAdapter().getChat().getId(), "index.html#"+messageRecord.getPOILocation()); + } + else { + int infoContactId = messageRecord.getInfoContactId(); + if (infoContactId != 0 && infoContactId != DC_CONTACT_ID_SELF) { + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, infoContactId); + startActivity(intent); + } + else { + String self_mail = dcContext.getConfig("configured_mail_user"); + if (self_mail != null && !self_mail.isEmpty() + && messageRecord.getText().contains(self_mail) + && getListAdapter().getChat().isDeviceTalk()) { + // This is a device message informing the user that the password is wrong + Intent intent = new Intent(getActivity(), EditRelayActivity.class); + intent.putExtra(EditRelayActivity.EXTRA_ADDR, self_mail); + startActivity(intent); + } + } + } + } + + @Override + public void onItemLongClick(DcMsg messageRecord, View view) { + if (actionMode == null) { + ((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord); + list.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); + addReactionView.show(messageRecord, view, () -> { + if (actionMode != null) { + actionMode.finish(); + } + }); + } + } + + private void jumpToOriginal(DcMsg original) { + if (original == null) { + Log.i(TAG, "Clicked on a quote or jump-to-original whose original message was deleted/non-existing."); + Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT).show(); + return; + } + + int foreignChatId = original.getChatId(); + if (foreignChatId != 0 && foreignChatId != chatId) { + Intent intent = new Intent(getActivity(), ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, foreignChatId); + int start = DcMsg.getMessagePosition(original, dcContext); + intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, start); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + ((ConversationActivity) getActivity()).hideSoftKeyboard(); + if (getActivity() != null) { + getActivity().startActivity(intent); + } else { + Log.e(TAG, "Activity was null"); + } + } else { + scrollMaybeSmoothToMsgId(original.getId()); + } + } + + @Override + public void onJumpToOriginalClicked(DcMsg messageRecord) { + jumpToOriginal(dcContext.getMsg(messageRecord.getOriginalMsgId())); + } + + @Override + public void onQuoteClicked(DcMsg messageRecord) { + jumpToOriginal(messageRecord.getQuotedMsg()); + } + + @Override + public void onShowFullClicked(DcMsg messageRecord) { + Intent intent = new Intent(getActivity(), FullMsgActivity.class); + intent.putExtra(FullMsgActivity.MSG_ID_EXTRA, messageRecord.getId()); + intent.putExtra(FullMsgActivity.BLOCK_LOADING_REMOTE, getListAdapter().getChat().isContactRequest()); + startActivity(intent); + getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out); + } + + @Override + public void onDownloadClicked(DcMsg messageRecord) { + dcContext.downloadFullMsg(messageRecord.getId()); + } + + @Override + public void onReactionClicked(DcMsg messageRecord) { + ReactionsDetailsFragment dialog = new ReactionsDetailsFragment(messageRecord.getId()); + dialog.show(getActivity().getSupportFragmentManager(), null); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CODE_ADD_EDIT_CONTACT && getContext() != null) { +// ApplicationContext.getInstance(getContext().getApplicationContext()) +// .getJobManager() +// .add(new DirectoryRefreshJob(getContext().getApplicationContext(), false)); + } + } + + private class ActionModeCallback implements ActionMode.Callback { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.conversation_context, menu); + + mode.setTitle("1"); + + Util.redMenuItem(menu, R.id.menu_context_delete_message); + setCorrectMenuVisibility(menu); + ConversationAdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth()); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + ((ConversationAdapter)list.getAdapter()).clearSelection(); + list.getAdapter().notifyDataSetChanged(); + + actionMode = null; + hideAddReactionView(); + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + hideAddReactionView(); + int itemId = item.getItemId(); + if (itemId == R.id.menu_context_copy) { + handleCopyMessage(getListAdapter().getSelectedItems()); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_context_delete_message) { + handleDeleteMessages((int) chatId, getListAdapter().getSelectedItems()); + return true; + } else if (itemId == R.id.menu_context_share) { + DcHelper.openForViewOrShare(getContext(), getSelectedMessageRecord(getListAdapter().getSelectedItems()).getId(), Intent.ACTION_SEND); + return true; + } else if (itemId == R.id.menu_context_details) { + handleDisplayDetails(getSelectedMessageRecord(getListAdapter().getSelectedItems())); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_context_forward) { + handleForwardMessage(getListAdapter().getSelectedItems()); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_add_to_home_screen) { + WebxdcActivity.addToHomeScreen(getActivity(), getSelectedMessageRecord(getListAdapter().getSelectedItems()).getId()); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_context_save_attachment) { + handleSaveAttachment(getListAdapter().getSelectedItems()); + return true; + } else if (itemId == R.id.menu_context_reply) { + handleReplyMessage(getSelectedMessageRecord(getListAdapter().getSelectedItems())); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_context_edit) { + handleEditMessage(getSelectedMessageRecord(getListAdapter().getSelectedItems())); + actionMode.finish(); + return true; + } else if (itemId == R.id.menu_context_reply_privately) { + handleReplyMessagePrivately(getSelectedMessageRecord(getListAdapter().getSelectedItems())); + return true; + } else if (itemId == R.id.menu_resend) { + handleResendMessage(getListAdapter().getSelectedItems()); + return true; + } else if (itemId == R.id.menu_toggle_save) { + handleToggleSave(getListAdapter().getSelectedItems()); + actionMode.finish(); + return true; + } + return false; + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + switch (event.getId()) { + case DcContext.DC_EVENT_MSGS_CHANGED: + if (event.getData1Int() == 0 // deleted messages or batch insert + || event.getData1Int() == chatId) { + reloadList(); + } + break; + + case DcContext.DC_EVENT_REACTIONS_CHANGED: + case DcContext.DC_EVENT_INCOMING_MSG: + case DcContext.DC_EVENT_MSG_DELIVERED: + case DcContext.DC_EVENT_MSG_FAILED: + case DcContext.DC_EVENT_MSG_READ: + if (event.getData1Int() == chatId) { + reloadList(); + } + break; + + case DcContext.DC_EVENT_CHAT_MODIFIED: + if (event.getData1Int() == chatId) { + updateLocationButton(); + reloadList(true); + } + break; + } + + // removing the "new message" marker on incoming messages may be a bit unexpected, + // esp. when a series of message is coming in and after the first, the screen is turned on, + // the "new message" marker will flash for a short moment and disappear. + /*if (eventId == DcContext.DC_EVENT_INCOMING_MSG && isResumed()) { + setLastSeen(-1); + }*/ + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java new file mode 100644 index 0000000000000000000000000000000000000000..52bf4527fde37f0f16f977fc694aa8da3f1f42a1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -0,0 +1,1007 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.text.Spannable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.BorderlessImageView; +import org.thoughtcrime.securesms.components.CallItemView; +import org.thoughtcrime.securesms.components.ConversationItemFooter; +import org.thoughtcrime.securesms.components.ConversationItemThumbnail; +import org.thoughtcrime.securesms.components.DocumentView; +import org.thoughtcrime.securesms.components.QuoteView; +import org.thoughtcrime.securesms.components.VcardView; +import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.mms.VcardSlide; +import org.thoughtcrime.securesms.reactions.ReactionsConversationView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Linkifier; +import org.thoughtcrime.securesms.util.LongClickMovementMethod; +import org.thoughtcrime.securesms.util.MarkdownUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.calls.CallUtil; + +import java.util.List; +import java.util.Set; + +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.CallInfo; +import chat.delta.rpc.types.CallState; +import chat.delta.rpc.types.Reactions; +import chat.delta.rpc.types.VcardContact; + +/** + * A view that displays an individual conversation item within a conversation + * thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter. + * + * @author Moxie Marlinspike + * + */ + +public class ConversationItem extends BaseConversationItem +{ + private static final String TAG = ConversationItem.class.getSimpleName(); + + private static final Rect SWIPE_RECT = new Rect(); + + private static final int MAX_MEASURE_CALLS = 3; + + private DcContact dcContact; + // Whether the sender's avatar and name should be shown (usually the case in group threads): + private boolean showSender; + private GlideRequests glideRequests; + + protected ViewGroup bodyBubble; + protected ReactionsConversationView reactionsView; + protected View replyView; + protected View jumptoView; + @Nullable private QuoteView quoteView; + private ConversationItemFooter footer; + private TextView groupSender; + private View groupSenderHolder; + private AvatarImageView contactPhoto; + protected ViewGroup contactPhotoHolder; + private ViewGroup container; + private Button msgActionButton; + private Button showFullButton; + + private @NonNull Stub mediaThumbnailStub; + private @NonNull Stub audioViewStub; + private @NonNull Stub documentViewStub; + private @NonNull Stub webxdcViewStub; + private Stub stickerStub; + private Stub vcardViewStub; + private Stub callViewStub; + private @Nullable EventListener eventListener; + + private int measureCalls; + + private int incomingBubbleColor; + private int outgoingBubbleColor; + + public ConversationItem(Context context) { + this(context, null); + } + + public ConversationItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + initializeAttributes(); + + this.bodyText = findViewById(R.id.conversation_item_body); + this.footer = findViewById(R.id.conversation_item_footer); + this.reactionsView = findViewById(R.id.reactions_view); + this.groupSender = findViewById(R.id.group_message_sender); + this.contactPhoto = findViewById(R.id.contact_photo); + this.contactPhotoHolder = findViewById(R.id.contact_photo_container); + this.bodyBubble = findViewById(R.id.body_bubble); + this.mediaThumbnailStub = new Stub<>(findViewById(R.id.image_view_stub)); + this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); + this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); + this.webxdcViewStub = new Stub<>(findViewById(R.id.webxdc_view_stub)); + this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); + this.vcardViewStub = new Stub<>(findViewById(R.id.vcard_view_stub)); + this.callViewStub = new Stub<>(findViewById(R.id.call_view_stub)); + this.groupSenderHolder = findViewById(R.id.group_sender_holder); + this.quoteView = findViewById(R.id.quote_view); + this.container = findViewById(R.id.container); + this.replyView = findViewById(R.id.reply_icon); + this.jumptoView = findViewById(R.id.jumpto_icon); + this.msgActionButton = findViewById(R.id.msg_action_button); + this.showFullButton = findViewById(R.id.show_full_button); + + setOnClickListener(new ClickListener(null)); + + bodyText.setOnLongClickListener(passthroughClickListener); + bodyText.setOnClickListener(passthroughClickListener); + + bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext())); + } + + @Override + public void bind(@NonNull DcMsg messageRecord, + @NonNull DcChat dcChat, + @NonNull GlideRequests glideRequests, + @NonNull Set batchSelected, + @NonNull Recipient recipients, + boolean pulseHighlight) + { + bind(messageRecord, dcChat, batchSelected, pulseHighlight, recipients); + this.glideRequests = glideRequests; + this.showSender = ((dcChat.isMultiUser() || dcChat.isSelfTalk()) && !messageRecord.isOutgoing()) || messageRecord.getOverrideSenderName() != null; + + if (showSender) { + this.dcContact = dcContext.getContact(messageRecord.getFromId()); + } + + if (dcChat.isSelfTalk() && messageRecord.getOriginalMsgId() != 0) { + jumptoView.setVisibility(View.VISIBLE); + jumptoView.setOnClickListener(view -> { + if (eventListener != null) { + eventListener.onJumpToOriginalClicked(messageRecord); + } + }); + } else { + jumptoView.setVisibility(View.GONE); + } + + setGutterSizes(messageRecord, showSender); + setMessageShape(messageRecord); + setMediaAttributes(messageRecord, showSender); + setBodyText(messageRecord); + setBubbleState(messageRecord); + setContactPhoto(); + setGroupMessageStatus(); + setAuthor(messageRecord, showSender); + setMessageSpacing(context); + setReactions(messageRecord); + setFooter(messageRecord); + setQuote(messageRecord); + if (Util.isTouchExplorationEnabled(context)) { + setContentDescription(); + } + } + + + @Override + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + } + + public boolean disallowSwipe(float downX, float downY) { + // If it is possible to reply to a message, it should also be possible to swipe it. + // For this to be possible we need a non-null reply icon. + // This means that `replyView != null` must always be the same as ConversationFragment.canReplyToMsg(messageRecord). + if (replyView == null) return true; + if (!dcChat.canSend()) return true; + + if (!hasAudio(messageRecord)) return false; + audioViewStub.get().getSeekBarGlobalVisibleRect(SWIPE_RECT); + return SWIPE_RECT.contains((int) downX, (int) downY); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (isInEditMode()) { + return; + } + + boolean needsMeasure = false; + + if (hasQuote(messageRecord)) { + if (quoteView == null) { + throw new AssertionError(); + } + int quoteWidth = quoteView.getMeasuredWidth(); + int availableWidth = getAvailableMessageBubbleWidth(quoteView); + + if (quoteWidth != availableWidth) { + quoteView.getLayoutParams().width = availableWidth; + needsMeasure = true; + } + } + + if (needsMeasure) { + if (measureCalls < MAX_MEASURE_CALLS) { + measureCalls++; + measure(widthMeasureSpec, heightMeasureSpec); + } else { + Log.w(TAG, "Hit measure() cap of " + MAX_MEASURE_CALLS); + } + } else { + measureCalls = 0; + } + } + + private void initializeAttributes() { + final int[] attributes = new int[] { + R.attr.conversation_item_incoming_bubble_color, + R.attr.conversation_item_outgoing_bubble_color, + }; + try (TypedArray attrs = context.obtainStyledAttributes(attributes)) { + incomingBubbleColor = attrs.getColor(0, Color.WHITE); + outgoingBubbleColor = attrs.getColor(1, Color.WHITE); + } + } + + @Override + public void unbind() { + } + + public DcMsg getMessageRecord() { + return messageRecord; + } + + /// DcMsg Attribute Parsers + + private void setBubbleState(DcMsg messageRecord) { + if (messageRecord.isOutgoing()) { + bodyBubble.getBackground().setColorFilter(outgoingBubbleColor, PorterDuff.Mode.MULTIPLY); + } else { + bodyBubble.getBackground().setColorFilter(incomingBubbleColor, PorterDuff.Mode.MULTIPLY); + } + } + + @Override + protected void setInteractionState(DcMsg messageRecord, boolean pulseHighlight) { + super.setInteractionState(messageRecord, pulseHighlight); + + if (mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + mediaThumbnailStub.get().setLongClickable(batchSelected.isEmpty()); + } + + if (audioViewStub.resolved()) { + audioViewStub.get().disablePlayer(!batchSelected.isEmpty()); + } + + if (documentViewStub.resolved()) { + documentViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + documentViewStub.get().setClickable(batchSelected.isEmpty()); + } + + if (webxdcViewStub.resolved()) { + webxdcViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + webxdcViewStub.get().setClickable(batchSelected.isEmpty()); + } + + if (vcardViewStub.resolved()) { + vcardViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + vcardViewStub.get().setClickable(batchSelected.isEmpty()); + } + + if (callViewStub.resolved()) { + callViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + callViewStub.get().setClickable(batchSelected.isEmpty()); + } + } + + private void setContentDescription() { + String desc = ""; + if (groupSenderHolder.getVisibility() == View.VISIBLE) { + desc = groupSender.getText() + "\n"; + } + + if (audioViewStub.resolved() && audioViewStub.get().getVisibility() == View.VISIBLE) { + desc += audioViewStub.get().getDescription() + "\n"; + } else if (documentViewStub.resolved() && documentViewStub.get().getVisibility() == View.VISIBLE) { + desc += documentViewStub.get().getDescription() + "\n"; + } else if (webxdcViewStub.resolved() && webxdcViewStub.get().getVisibility() == View.VISIBLE) { + desc += webxdcViewStub.get().getDescription() + "\n"; + } else if (vcardViewStub.resolved() && vcardViewStub.get().getVisibility() == View.VISIBLE) { + desc += vcardViewStub.get().getDescription() + "\n"; + } else if (callViewStub.resolved() && callViewStub.get().getVisibility() == View.VISIBLE) { + desc += callViewStub.get().getDescription() + "\n"; + } else if (mediaThumbnailStub.resolved() && mediaThumbnailStub.get().getVisibility() == View.VISIBLE) { + desc += mediaThumbnailStub.get().getDescription() + "\n"; + } else if (stickerStub.resolved() && stickerStub.get().getVisibility() == View.VISIBLE) { + desc += stickerStub.get().getDescription() + "\n"; + } + + if (bodyText.getVisibility() == View.VISIBLE) { + desc += bodyText.getText() + "\n"; + } + + if (footer.getVisibility() == View.VISIBLE) { + desc += footer.getDescription(); + } + + this.setContentDescription(desc); + } + + private boolean hasAudio(DcMsg messageRecord) { + int type = messageRecord.getType(); + return type==DcMsg.DC_MSG_AUDIO || type==DcMsg.DC_MSG_VOICE; + } + + private boolean hasQuote(DcMsg messageRecord) { + return !"".equals(messageRecord.getQuotedText()); + } + + private boolean hasThumbnail(DcMsg messageRecord) { + int type = messageRecord.getType(); + return type==DcMsg.DC_MSG_GIF || type==DcMsg.DC_MSG_IMAGE || type==DcMsg.DC_MSG_VIDEO; + } + + private boolean hasSticker(DcMsg dcMsg) { + return dcMsg.getType()==DcMsg.DC_MSG_STICKER; + } + + private boolean hasOnlyThumbnail(DcMsg messageRecord) { + return hasThumbnail(messageRecord) && + !hasAudio(messageRecord) && + !hasDocument(messageRecord) && + !hasWebxdc(messageRecord) && + !hasSticker(messageRecord); + } + + private boolean hasWebxdc(DcMsg dcMsg) { + return dcMsg.getType()==DcMsg.DC_MSG_WEBXDC; + } + + private boolean hasVcard(DcMsg dcMsg) { + return dcMsg.getType()==DcMsg.DC_MSG_VCARD; + } + + private boolean hasDocument(DcMsg dcMsg) { + return dcMsg.getType()==DcMsg.DC_MSG_FILE; + } + + private void setBodyText(DcMsg messageRecord) { + bodyText.setClickable(false); + bodyText.setFocusable(false); + + String text = messageRecord.getText(); + + if (messageRecord.getType() == DcMsg.DC_MSG_CALL || text.isEmpty()) { + bodyText.setVisibility(View.GONE); + } + else { + Spannable spannable = (Spannable) MarkdownUtil.toMarkdown(context, text); + if (batchSelected.isEmpty()) { + spannable = Linkifier.linkify(spannable); + } + bodyText.setText(spannable); + bodyText.setVisibility(View.VISIBLE); + } + + int downloadState = messageRecord.getDownloadState(); + if (downloadState == DcMsg.DC_DOWNLOAD_AVAILABLE || downloadState == DcMsg.DC_DOWNLOAD_FAILURE || downloadState == DcMsg.DC_DOWNLOAD_IN_PROGRESS) { + showFullButton.setVisibility(View.GONE); + msgActionButton.setVisibility(View.VISIBLE); + if (downloadState==DcMsg.DC_DOWNLOAD_IN_PROGRESS) { + msgActionButton.setEnabled(false); + msgActionButton.setText(R.string.downloading); + } else if (downloadState==DcMsg.DC_DOWNLOAD_FAILURE) { + msgActionButton.setEnabled(true); + msgActionButton.setText(R.string.download_failed); + } else { + msgActionButton.setEnabled(true); + msgActionButton.setText(R.string.download); + } + + msgActionButton.setOnClickListener(view -> { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onDownloadClicked(messageRecord); + } else { + passthroughClickListener.onClick(view); + } + }); + } else if (messageRecord.getType() == DcMsg.DC_MSG_WEBXDC) { + showFullButton.setVisibility(View.GONE); + msgActionButton.setVisibility(View.VISIBLE); + msgActionButton.setEnabled(true); + msgActionButton.setText(R.string.start_app); + msgActionButton.setOnClickListener(view -> { + if (batchSelected.isEmpty()) { + WebxdcActivity.openWebxdcActivity(getContext(), messageRecord); + } else { + passthroughClickListener.onClick(view); + } + }); + } + else if (messageRecord.hasHtml()) { + msgActionButton.setVisibility(View.GONE); + showFullButton.setVisibility(View.VISIBLE); + showFullButton.setEnabled(true); + showFullButton.setText(R.string.show_full_message); + showFullButton.setOnClickListener(view -> { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onShowFullClicked(messageRecord); + } else { + passthroughClickListener.onClick(view); + } + }); + } else { + msgActionButton.setVisibility(View.GONE); + showFullButton.setVisibility(View.GONE); + } + } + + private void setMediaAttributes(@NonNull DcMsg messageRecord, + boolean showSender) + { + class SetDurationListener implements AudioSlidePlayer.Listener { + @Override + public void onStart() {} + + @Override + public void onStop() {} + + @Override + public void onProgress(AudioSlide slide, double progress, long millis) {} + + @Override + public void onReceivedDuration(int millis) { + messageRecord.lateFilingMediaSize(0,0, millis); + audioViewStub.get().setDuration(millis); + } + } + if (hasAudio(messageRecord)) { + audioViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + //noinspection ConstantConditions + int duration = messageRecord.getDuration(); + if (duration == 0) { + AudioSlide audio = new AudioSlide(context, messageRecord); + AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, new SetDurationListener()); + audioSlidePlayer.requestDuration(); + } + + audioViewStub.get().setAudio(new AudioSlide(context, messageRecord), duration); + audioViewStub.get().setOnClickListener(passthroughClickListener); + audioViewStub.get().setOnLongClickListener(passthroughClickListener); + audioViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + else if (hasDocument(messageRecord)) { + documentViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + //noinspection ConstantConditions + documentViewStub.get().setDocument(new DocumentSlide(context, messageRecord)); + documentViewStub.get().setDocumentClickListener(new ThumbnailClickListener()); + documentViewStub.get().setOnLongClickListener(passthroughClickListener); + documentViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + else if (hasWebxdc(messageRecord)) { + webxdcViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + webxdcViewStub.get().setWebxdc(messageRecord, context.getString(R.string.webxdc_app)); + webxdcViewStub.get().setWebxdcClickListener(new ThumbnailClickListener()); + webxdcViewStub.get().setOnLongClickListener(passthroughClickListener); + webxdcViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + else if (hasVcard(messageRecord)) { + vcardViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + vcardViewStub.get().setVcard(glideRequests, new VcardSlide(context, messageRecord), rpc); + vcardViewStub.get().setVcardClickListener(new ThumbnailClickListener()); + vcardViewStub.get().setOnLongClickListener(passthroughClickListener); + + vcardViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + else if (messageRecord.getType() == DcMsg.DC_MSG_CALL) { + callViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + + try { + callViewStub.get().setCallItem(messageRecord.isOutgoing(), rpc.callInfo(dcContext.getAccountId(), messageRecord.getId())); + } catch (RpcException e) { + Log.e(TAG, "Error in Rpc.callInfo", e); + } + callViewStub.get().setCallClickListener(new CallClickListener()); + callViewStub.get().setOnLongClickListener(passthroughClickListener); + + callViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + else if (hasThumbnail(messageRecord)) { + mediaThumbnailStub.get().setVisibility(View.VISIBLE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + Slide slide = MediaUtil.getSlideForMsg(context, messageRecord); + + MediaUtil.ThumbnailSize thumbnailSize = new MediaUtil.ThumbnailSize(messageRecord.getWidth(0), messageRecord.getHeight(0)); + if ((thumbnailSize.width<=0||thumbnailSize.height<=0)) { + if(messageRecord.getType()==DcMsg.DC_MSG_VIDEO) { + MediaUtil.createVideoThumbnailIfNeeded(context, slide.getUri(), slide.getThumbnailUri(), thumbnailSize); + } + if (thumbnailSize.width<=0||thumbnailSize.height<=0) { + thumbnailSize.width = 180; + thumbnailSize.height = 180; + } + messageRecord.lateFilingMediaSize(thumbnailSize.width, thumbnailSize.height, 0); + } + + mediaThumbnailStub.get().setImageResource(glideRequests, + slide, + thumbnailSize.width, + thumbnailSize.height); + mediaThumbnailStub.get().setThumbnailClickListener(new ThumbnailClickListener()); + mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener); + mediaThumbnailStub.get().setOnClickListener(passthroughClickListener); + mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getText())); + mediaThumbnailStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + setThumbnailOutlineCorners(messageRecord, showSender); + + bodyBubble.getLayoutParams().width = ViewUtil.dpToPx(readDimen(R.dimen.media_bubble_max_width)); + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + else if (hasSticker(messageRecord)) { + stickerStub.get().setVisibility(View.VISIBLE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + bodyBubble.setBackgroundColor(Color.TRANSPARENT); + + stickerStub.get().setSlide(glideRequests, new StickerSlide(context, messageRecord)); + stickerStub.get().setThumbnailClickListener(new StickerClickListener()); + stickerStub.get().setOnLongClickListener(passthroughClickListener); + stickerStub.get().setOnClickListener(passthroughClickListener); + stickerStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } + else { + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); + if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); + if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } + } + + private void setThumbnailOutlineCorners(@NonNull DcMsg current, + boolean showSender) + { + int defaultRadius = readDimen(R.dimen.message_corner_radius); + + int topLeft = defaultRadius; + int topRight = defaultRadius; + int bottomLeft = defaultRadius; + int bottomRight = defaultRadius; + + if (!TextUtils.isEmpty(current.getText())) { + bottomLeft = 0; + bottomRight = 0; + } + + if (showSender + || current.isForwarded() + || hasQuote(current)) { + topLeft = 0; + topRight = 0; + } + + if(bottomLeft != 0 && bottomRight !=0) { + if((current.isOutgoing() && ViewUtil.isLtr(this)) || (!current.isOutgoing() && ViewUtil.isRtl(this))) { + bottomRight = 0; + } + else { + bottomLeft = 0; + } + } + + mediaThumbnailStub.get().setOutlineCorners(topLeft, topRight, bottomRight, bottomLeft); + } + + private void setContactPhoto() { + if (contactPhoto == null) return; + + if (!showSender || dcContact ==null) { + contactPhoto.setVisibility(View.GONE); + } else { + contactPhoto.setAvatar(glideRequests, new Recipient(context, dcContact), true); + contactPhoto.setVisibility(View.VISIBLE); + } + } + + private void setQuote(@NonNull DcMsg current) { + if (quoteView == null) { + throw new AssertionError(); + } + String quoteTxt = current.getQuotedText(); + if (quoteTxt == null || quoteTxt.isEmpty()) { + quoteView.dismiss(); + if (mediaThumbnailStub.resolved()) { + ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0); + } else if (stickerStub.resolved()) { + ViewUtil.setTopMargin(stickerStub.get(), 0); + } + return; + } + DcMsg msg = current.getQuotedMsg(); + + // If you modify these lines you may also want to modify ConversationActivity.handleReplyMessage(): + Recipient author = null; + SlideDeck slideDeck = new SlideDeck(); + if (msg != null) { + author = new Recipient(context, dcContext.getContact(msg.getFromId())); + if (msg.getType() != DcMsg.DC_MSG_TEXT) { + Slide slide = MediaUtil.getSlideForMsg(context, msg); + if (slide != null) { + slideDeck.addSlide(slide); + } + } + } + + quoteView.setQuote(GlideApp.with(this), + msg, + author, + quoteTxt, + slideDeck, + current.getType() == DcMsg.DC_MSG_STICKER, + false); + + quoteView.setVisibility(View.VISIBLE); + quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; + + quoteView.setOnClickListener(view -> { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onQuoteClicked(current); + } else { + passthroughClickListener.onClick(view); + } + }); + + quoteView.setOnLongClickListener(passthroughClickListener); + + if (mediaThumbnailStub.resolved()) { + ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding)); + } else if (stickerStub.resolved()) { + ViewUtil.setTopMargin(stickerStub.get(), readDimen(R.dimen.message_bubble_top_padding)); + } + } + + private void setGutterSizes(@NonNull DcMsg current, boolean showSender) { + if (showSender && current.isOutgoing()) { + ViewUtil.setLeftMargin(container, readDimen(R.dimen.conversation_group_left_gutter)); + } else if (current.isOutgoing()) { + ViewUtil.setLeftMargin(container, readDimen(R.dimen.conversation_individual_left_gutter)); + } + } + + private void setFooter(@NonNull DcMsg current) { + ViewUtil.updateLayoutParams(footer, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + + footer.setVisibility(GONE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().getFooter().setVisibility(GONE); + + ConversationItemFooter activeFooter = getActiveFooter(current); + activeFooter.setVisibility(VISIBLE); + activeFooter.setMessageRecord(current); + } + + private void setReactions(@NonNull DcMsg current) { + try { + Reactions reactions = rpc.getMessageReactions(dcContext.getAccountId(), current.getId()); + if (reactions == null) { + reactionsView.clear(); + } else { + reactionsView.setReactions(reactions.reactions); + reactionsView.setOnClickListener(view -> { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onReactionClicked(current); + } else { + passthroughClickListener.onClick(view); + } + }); + } + } catch (RpcException e) { + reactionsView.clear(); + } + } + + private ConversationItemFooter getActiveFooter(@NonNull DcMsg messageRecord) { + if (hasSticker(messageRecord)) { + return stickerStub.get().getFooter(); + } else if (hasOnlyThumbnail(messageRecord) && TextUtils.isEmpty(messageRecord.getText())) { + return mediaThumbnailStub.get().getFooter(); + } else if (messageRecord.getType() == DcMsg.DC_MSG_CALL) { + return callViewStub.get().getFooter(); + } else { + return footer; + } + } + + private int readDimen(@DimenRes int dimenId) { + return context.getResources().getDimensionPixelOffset(dimenId); + } + + private void setGroupMessageStatus() { + if (messageRecord.getType()==DcMsg.DC_MSG_STICKER) { + this.groupSender.setVisibility(GONE); + return; + } else { + this.groupSender.setVisibility(VISIBLE); + } + + if (messageRecord.isForwarded()) { + if (showSender && dcContact !=null) { + this.groupSender.setText(context.getString(R.string.forwarded_by, messageRecord.getSenderName(dcContact))); + } else { + this.groupSender.setText(context.getString(R.string.forwarded_message)); + } + this.groupSender.setTextColor(context.getResources().getColor(R.color.unknown_sender)); + } + else if (showSender && dcContact !=null) { + this.groupSender.setText(messageRecord.getSenderName(dcContact)); + this.groupSender.setTextColor(Util.rgbToArgbColor(dcContact.getColor())); + } + } + + private void setAuthor(@NonNull DcMsg current, boolean showSender) { + int groupSenderHolderVisibility = GONE; + if (showSender) { + if (contactPhotoHolder != null) { + contactPhotoHolder.setVisibility(VISIBLE); + contactPhoto.setVisibility(VISIBLE); + } + groupSenderHolderVisibility = VISIBLE; + } else { + if (contactPhotoHolder != null) { + contactPhotoHolder.setVisibility(GONE); + } + } + + if(current.isForwarded()) { + groupSenderHolderVisibility = VISIBLE; + } + + groupSenderHolder.setVisibility(groupSenderHolderVisibility); + + boolean collapse = false; + if(groupSenderHolderVisibility==VISIBLE && current.getType()==DcMsg.DC_MSG_TEXT) { + collapse = true; + } + + int spacingTop = collapse? 0 /*2dp border come from the senderHolder*/ : readDimen(context, R.dimen.message_bubble_top_padding); + ViewUtil.setPaddingTop(bodyText, spacingTop); + } + + private void setMessageShape(@NonNull DcMsg current) { + int background; + background = current.isOutgoing() ? R.drawable.message_bubble_background_sent_alone + : R.drawable.message_bubble_background_received_alone; + bodyBubble.setBackgroundResource(background); + } + + private void setMessageSpacing(@NonNull Context context) { + int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse); + int spacingBottom = spacingTop; + + ViewUtil.setPaddingTop(this, spacingTop); + ViewUtil.setPaddingBottom(this, spacingBottom); + } + + private int readDimen(@NonNull Context context, @DimenRes int dimenId) { + return context.getResources().getDimensionPixelOffset(dimenId); + } + + private int getAvailableMessageBubbleWidth(@NonNull View forView) { + int availableWidth; + if (hasAudio(messageRecord)) { + availableWidth = audioViewStub.get().getMeasuredWidth() + ViewUtil.getLeftMargin(audioViewStub.get()) + ViewUtil.getRightMargin(audioViewStub.get()); + } else if (hasThumbnail(messageRecord)) { + availableWidth = mediaThumbnailStub.get().getMeasuredWidth(); + } else { + availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight(); + } + + availableWidth -= ViewUtil.getLeftMargin(forView) + ViewUtil.getRightMargin(forView); + + return availableWidth; + } + + @Override + public void onAccessibilityClick() { + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().performClick(); + else if (audioViewStub.resolved()) audioViewStub.get().togglePlay(); + else if (documentViewStub.resolved()) documentViewStub.get().performClick(); + else if (webxdcViewStub.resolved()) webxdcViewStub.get().performClick(); + else if (vcardViewStub.resolved()) vcardViewStub.get().performClick(); + else if (callViewStub.resolved()) callViewStub.get().performClick(); + } + + /// Event handlers + + private class ThumbnailClickListener implements SlideClickListener { + public void onClick(final View v, final Slide slide) { + if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { + performClick(); + } else if (slide.isWebxdcDocument()) { + WebxdcActivity.openWebxdcActivity(context, messageRecord); + } else if (slide.isVcard()) { + try { + String path = slide.asAttachment().getRealPath(context); + VcardContact vcardContact = rpc.parseVcard(path).get(0); + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.ask_start_chat_with, vcardContact.displayName)) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + try { + List contactIds = rpc.importVcard(dcContext.getAccountId(), path); + if (!contactIds.isEmpty()) { + int chatId = dcContext.createChatByContactId(contactIds.get(0)); + if (chatId != 0) { + Intent intent = new Intent(context, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + context.startActivity(intent); + return; + } + } + } catch (RpcException e) { + Log.e(TAG, "failed to import vCard", e); + } + Toast.makeText(context, context.getResources().getString(R.string.error), Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } catch (RpcException e) { + Log.e(TAG, "failed to parse vCard", e); + Toast.makeText(context, context.getResources().getString(R.string.error), Toast.LENGTH_SHORT).show(); + } + } else if (MediaPreviewActivity.isTypeSupported(slide) && slide.getUri() != null) { + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(MediaPreviewActivity.DC_MSG_ID, slide.getDcMsgId()); + intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, conversationRecipient.getAddress()); + intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, messageRecord.isOutgoing()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, false); + + context.startActivity(intent); + } else if (slide.getUri() != null) { + DcHelper.openForViewOrShare(context, slide.getDcMsgId(), Intent.ACTION_VIEW); + } + } + } + + private class StickerClickListener implements SlideClickListener { + public void onClick(final View v, final Slide slide) { + if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { + performClick(); + } + } + } + + private class CallClickListener implements CallItemView.CallClickListener { + public void onClick(final View v, final CallInfo callInfo) { + if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { + performClick(); + } else { + int accId = dcContext.getAccountId(); + int chatId = messageRecord.getChatId(); + if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) { + int callId = messageRecord.getId(); + CallUtil.openCall(getContext(), accId, chatId, callId, callInfo.sdpOffer); + } else { + CallUtil.startCall(getContext(), accId, chatId); + } + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItemSwipeCallback.java b/src/main/java/org/thoughtcrime/securesms/ConversationItemSwipeCallback.java new file mode 100644 index 0000000000000000000000000000000000000000..fd2f7cb84b5c1031119ce5498d57650dc331ea10 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItemSwipeCallback.java @@ -0,0 +1,218 @@ +package org.thoughtcrime.securesms; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.os.Vibrator; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.util.AccessibilityUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; + +class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { + + private static final float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX; + private static final long SWIPE_SUCCESS_VIBE_TIME_MS = 10; + + private boolean swipeBack; + private boolean shouldTriggerSwipeFeedback; + private boolean canTriggerSwipe; + private float latestDownX; + private float latestDownY; + + private final SwipeAvailabilityProvider swipeAvailabilityProvider; + private final ConversationItemTouchListener itemTouchListener; + private final OnSwipeListener onSwipeListener; + + ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider, + @NonNull OnSwipeListener onSwipeListener) + { + super(0, ItemTouchHelper.END); + this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate); + this.swipeAvailabilityProvider = swipeAvailabilityProvider; + this.onSwipeListener = onSwipeListener; + this.shouldTriggerSwipeFeedback = true; + this.canTriggerSwipe = true; + } + + void attachToRecyclerView(@NonNull RecyclerView recyclerView) { + recyclerView.addOnItemTouchListener(itemTouchListener); + new ItemTouchHelper(this).attachToRecyclerView(recyclerView); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) + { + return false; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + + @Override + public int getSwipeDirs(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) + { + if (cannotSwipeViewHolder(viewHolder)) return 0; + return super.getSwipeDirs(recyclerView, viewHolder); + } + + @Override + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + if (swipeBack) { + swipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + @Override + public void onChildDraw( + @NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx, float dy, int actionState, boolean isCurrentlyActive) + { + if (cannotSwipeViewHolder(viewHolder)) return; + + float sign = getSignFromDirection(viewHolder.itemView); + boolean isCorrectSwipeDir = sameSign(dx, sign); + + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign); + handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx)); + if (canTriggerSwipe) { + setTouchListener(recyclerView, viewHolder, Math.abs(dx)); + } + } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1); + } + + if (dx == 0) { + shouldTriggerSwipeFeedback = true; + canTriggerSwipe = true; + } + } + + private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) { + if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) { + vibrate(item.getContext()); + ConversationSwipeAnimationHelper.trigger(item); + shouldTriggerSwipeFeedback = false; + } + } + + private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) { + if (cannotSwipeViewHolder(viewHolder)) return; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + DcMsg messageRecord = item.getMessageRecord(); + + onSwipeListener.onSwipe(messageRecord); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx) + { + recyclerView.setOnTouchListener(new View.OnTouchListener() { + + // This variable is necessary to make sure that the handleTouchActionUp() and therefore onSwiped() is called only once. + // Otherwise, any subsequent little swipe would invoke onSwiped(). + // We can't call recyclerView.setOnTouchListener(null) because another ConversationItem might have set its own + // on touch listener in the meantime and we don't want to cancel it + private boolean listenerCalled = false; + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + shouldTriggerSwipeFeedback = true; + break; + case MotionEvent.ACTION_UP: + if (!listenerCalled) { + listenerCalled = true; + ConversationItemSwipeCallback.this.handleTouchActionUp(recyclerView, viewHolder, dx); + } + //fallthrough + case MotionEvent.ACTION_CANCEL: + swipeBack = true; + shouldTriggerSwipeFeedback = false; + // Sometimes the view does not go back correctly, so make sure that after 2s the progress is reset: + viewHolder.itemView.postDelayed(() -> resetProgress(viewHolder), 2000); + if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) { + resetProgress(viewHolder); + } + break; + } + return false; + } + + }); + } + + private void handleTouchActionUp(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx) + { + if (dx > SWIPE_SUCCESS_DX) { + canTriggerSwipe = false; + onSwiped(viewHolder); + if (shouldTriggerSwipeFeedback) { + vibrate(viewHolder.itemView.getContext()); + } + } + recyclerView.cancelPendingInputEvents(); + } + + private static void resetProgress(RecyclerView.ViewHolder viewHolder) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, + 0f, + getSignFromDirection(viewHolder.itemView)); + } + + private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder.itemView instanceof ConversationItem)) return true; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) || + item.disallowSwipe(latestDownX, latestDownY); + } + + private void updateLatestDownCoordinate(float x, float y) { + latestDownX = x; + latestDownY = y; + } + + private static float getSignFromDirection(@NonNull View view) { + return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? -1f : 1f; + } + + private static boolean sameSign(float dX, float sign) { + return dX * sign > 0; + } + + private static void vibrate(@NonNull Context context) { + Vibrator vibrator = ServiceUtil.getVibrator(context); + if (vibrator != null) vibrator.vibrate(SWIPE_SUCCESS_VIBE_TIME_MS); + } + + interface SwipeAvailabilityProvider { + boolean isSwipeAvailable(DcMsg conversationMessage); + } + + interface OnSwipeListener { + void onSwipe(DcMsg conversationMessage); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItemTouchListener.java b/src/main/java/org/thoughtcrime/securesms/ConversationItemTouchListener.java new file mode 100644 index 0000000000000000000000000000000000000000..72dda4bde020a962c333788b154766bdaba7c5bf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItemTouchListener.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms; + +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +final class ConversationItemTouchListener extends RecyclerView.SimpleOnItemTouchListener { + + private final Callback callback; + + ConversationItemTouchListener(Callback callback) { + this.callback = callback; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + if (e.getAction() == MotionEvent.ACTION_DOWN) { + callback.onDownEvent(e.getRawX(), e.getRawY()); + } + return false; + } + + interface Callback { + void onDownEvent(float rawX, float rawY); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..66a14744054edb982ed283fa24c0f0fb07fbafa5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java @@ -0,0 +1,591 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.ConversationActivity.CHAT_ID_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.STARTING_POSITION_EXTRA; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_URL; +import static org.thoughtcrime.securesms.util.ShareUtil.acquireRelayMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.getDirectSharingChatId; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedTitle; +import static org.thoughtcrime.securesms.util.ShareUtil.isDirectSharing; +import static org.thoughtcrime.securesms.util.ShareUtil.isForwarding; +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.resetRelayingMessageContent; + +import android.Manifest; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.view.MenuCompat; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.components.SearchToolbar; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.connect.DirectShareUtil; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.proxy.ProxySettingsActivity; +import org.thoughtcrime.securesms.qr.QrActivity; +import org.thoughtcrime.securesms.qr.QrCodeHandler; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.search.SearchFragment; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.SendRelayedMessageUtil; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.Date; + +public class ConversationListActivity extends PassphraseRequiredActionBarActivity + implements ConversationListFragment.ConversationSelectedListener +{ + private static final String TAG = ConversationListActivity.class.getSimpleName(); + private static final String OPENPGP4FPR = "openpgp4fpr"; + private static final String NDK_ARCH_WARNED = "ndk_arch_warned"; + public static final String CLEAR_NOTIFICATIONS = "clear_notifications"; + public static final String ACCOUNT_ID_EXTRA = "account_id"; + public static final String FROM_WELCOME = "from_welcome"; + + private ConversationListFragment conversationListFragment; + public TextView title; + private AvatarView selfAvatar; + private ImageView unreadIndicator; + private SearchFragment searchFragment; + private SearchToolbar searchToolbar; + private ImageView searchAction; + private ViewGroup fragmentContainer; + private ViewGroup selfAvatarContainer; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + // update messages - for new messages, do not reuse or modify strings but create new ones. + // it is not needed to keep all past update messages, however, when deleted, also the strings should be deleted. + try { + DcContext dcContext = DcHelper.getContext(this); + final String deviceMsgLabel = "update_2_33_1_android"; + if (!dcContext.wasDeviceMsgEverAdded(deviceMsgLabel)) { + DcMsg msg = null; + if (!getIntent().getBooleanExtra(FROM_WELCOME, false)) { + msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + + // InputStream inputStream = getResources().getAssets().open("device-messages/green-checkmark.jpg"); + // String outputFile = DcHelper.getBlobdirFile(dcContext, "green-checkmark", ".jpg"); + // Util.copy(inputStream, new FileOutputStream(outputFile)); + // msg.setFile(outputFile, "image/jpeg"); + + msg.setText(getString(R.string.update_2_33, "https://arcanechat.me/#contribute")); + } + dcContext.addDeviceMsg(deviceMsgLabel, msg); + + if (Prefs.getStringPreference(this, Prefs.LAST_DEVICE_MSG_LABEL, "").equals(deviceMsgLabel)) { + int deviceChatId = dcContext.getChatIdByContactId(DcContact.DC_CONTACT_ID_DEVICE); + if (deviceChatId != 0) { + dcContext.marknoticedChat(deviceChatId); + } + } + Prefs.setStringPreference(this, Prefs.LAST_DEVICE_MSG_LABEL, deviceMsgLabel); + } + + } catch(Exception e) { + e.printStackTrace(); + } + + // create view + setContentView(R.layout.conversation_list_activity); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + selfAvatar = findViewById(R.id.self_avatar); + selfAvatarContainer = findViewById(R.id.self_avatar_container); + unreadIndicator = findViewById(R.id.unread_indicator); + title = findViewById(R.id.toolbar_title); + searchToolbar = findViewById(R.id.search_toolbar); + searchAction = findViewById(R.id.search_action); + fragmentContainer = findViewById(R.id.fragment_container); + + // add margin to avoid content hidden behind system bars + ViewUtil.applyWindowInsetsAsMargin(searchToolbar, true, true, true, false); + + Bundle bundle = new Bundle(); + conversationListFragment = initFragment(R.id.fragment_container, new ConversationListFragment(), bundle); + + initializeSearchListener(); + + TooltipCompat.setTooltipText(searchAction, getText(R.string.search_explain)); + + TooltipCompat.setTooltipText(selfAvatar, getText(R.string.switch_account)); + selfAvatar.setOnClickListener(v -> AccountManager.getInstance().showSwitchAccountMenu(this)); + findViewById(R.id.avatar_and_title).setOnClickListener(v -> { + if (!isRelayingMessageContent(this)) { + AccountManager.getInstance().showSwitchAccountMenu(this); + } + }); + + refresh(); + + if (BuildConfig.DEBUG) checkNdkArchitecture(); + + DcHelper.maybeShowMigrationError(this); + } + + /** + * If the build script is invoked with a specific architecture (e.g.`ndk-make.sh arm64-v8a`), it + * will compile the core only for this arch. This method checks if the arch was correct. + * + * In order to do this, `ndk-make.sh` writes its argument into the file `ndkArch`. + * `getNdkArch()` in `build.gradle` then reads this file and its content is assigned to + * `BuildConfig.NDK_ARCH`. + */ + @SuppressWarnings("ConstantConditions") + private void checkNdkArchitecture() { + boolean wrongArch = false; + + if (!TextUtils.isEmpty(BuildConfig.NDK_ARCH)) { + String archProperty = System.getProperty("os.arch"); + String arch; + + // armv8l is 32 bit mode in 64 bit CPU: + if (archProperty.startsWith("armv7") || archProperty.startsWith("armv8l")) arch = "armeabi-v7a"; + else if (archProperty.equals("aarch64")) arch = "arm64-v8a"; + else if (archProperty.equals("i686")) arch = "x86"; + else if (archProperty.equals("x86_64")) arch = "x86_64"; + else { + Log.e(TAG, "Unknown os.arch: " + archProperty); + arch = ""; + } + + if (!arch.equals(BuildConfig.NDK_ARCH)) { + wrongArch = true; + + String message; + if (arch.equals("")) { + message = "This phone has the unknown architecture " + archProperty + ".\n\n"+ + "Please open an issue at https://github.com/deltachat/deltachat-android/issues."; + } else { + message = "Apparently you used `ndk-make.sh " + BuildConfig.NDK_ARCH + "`, but this device is " + arch + ".\n\n" + + "You can use the app, but changes you made to the Rust code were not applied.\n\n" + + "To compile in your changes, you can:\n" + + "- Either run `ndk-make.sh " + arch + "` to build only for " + arch + " in debug mode\n" + + "- Or run `ndk-make.sh` without argument to build for all architectures in release mode\n\n" + + "If something doesn't work, please open an issue at https://github.com/deltachat/deltachat-android/issues!!"; + } + Log.e(TAG, message); + + if (!Prefs.getBooleanPreference(this, NDK_ARCH_WARNED, false)) { + new AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + Prefs.setBooleanPreference(this, NDK_ARCH_WARNED, true); + } + } + } + + if (!wrongArch) Prefs.setBooleanPreference(this, NDK_ARCH_WARNED, false); + } + + @Override + protected void onNewIntent(Intent intent) { + if (isFinishing()) { + Log.w(TAG, "Activity is finishing, aborting onNewIntent()"); + return; + } + super.onNewIntent(intent); + setIntent(intent); + refresh(); + conversationListFragment.onNewIntent(); + invalidateOptionsMenu(); + } + + private void refresh() { + DcContext dcContext = DcHelper.getContext(this); + int accountId = getIntent().getIntExtra(ACCOUNT_ID_EXTRA, dcContext.getAccountId()); + if (getIntent().getBooleanExtra(CLEAR_NOTIFICATIONS, false)) { + DcHelper.getNotificationCenter(this).removeAllNotifications(accountId); + } + if (accountId != dcContext.getAccountId()) { + AccountManager.getInstance().switchAccountAndStartActivity(this, accountId); + } + + refreshAvatar(); + refreshUnreadIndicator(); + refreshTitle(); + handleOpenpgp4fpr(); + if (isDirectSharing(this)) { + openConversation(getDirectSharingChatId(this), -1); + } + } + + public void refreshTitle() { + if (isRelayingMessageContent(this)) { + if (isForwarding(this)) { + title.setText(R.string.forward_to); + } else { + String titleStr = getSharedTitle(this); + if (titleStr != null) { // sharing from sendToChat + title.setText(titleStr); + } else { // normal sharing + title.setText(R.string.chat_share_with_title); + } + } + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } else { + title.setText(DcHelper.getContext(this).getName()); + // refreshTitle is called by ConversationListFragment when connectivity changes so update connectivity dot here + selfAvatar.setConnectivity(DcHelper.getContext(this).getConnectivity()); + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + } + } + + public void refreshAvatar() { + if (selfAvatarContainer == null) return; + + if (isRelayingMessageContent(this)) { + selfAvatarContainer.setVisibility(View.GONE); + } else { + selfAvatarContainer.setVisibility(View.VISIBLE); + DcContext dcContext = DcHelper.getContext(this); + DcContact self = dcContext.getContact(DcContact.DC_CONTACT_ID_SELF); + String name = dcContext.getConfig("displayname"); + if (TextUtils.isEmpty(name)) { + name = self.getAddr(); + } + selfAvatar.setAvatar(GlideApp.with(this), new Recipient(this, self, name), false); + } + } + + public void refreshUnreadIndicator() { + int unreadCount = 0; + DcAccounts dcAccounts = DcHelper.getAccounts(this); + int skipId = dcAccounts.getSelectedAccount().getAccountId(); + for (int accountId : dcAccounts.getAll()) { + if (accountId != skipId) { + DcContext dcContext = dcAccounts.getAccount(accountId); + if (!dcContext.isMuted()) { + unreadCount += dcContext.getFreshMsgs().length; + } + } + } + + if(unreadCount == 0) { + unreadIndicator.setVisibility(View.GONE); + } else { + boolean isDarkTheme = DynamicTheme.isDarkTheme(this); + int badgeColor = Color.WHITE; + if (isDarkTheme) { + final TypedArray attrs = obtainStyledAttributes(new int[] { + R.attr.conversation_list_item_unreadcount_color, + }); + badgeColor = attrs.getColor(0, Color.BLACK); + } + String label = unreadCount>99? "99+" : String.valueOf(unreadCount); + unreadIndicator.setImageDrawable(TextDrawable.builder() + .beginConfig() + .width(ViewUtil.dpToPx(this, 20)) + .height(ViewUtil.dpToPx(this, 20)) + .textColor(isDarkTheme? Color.WHITE : Color.BLACK) + .bold() + .endConfig() + .buildRound(label, badgeColor)); + unreadIndicator.setVisibility(View.VISIBLE); + } + } + + @Override + public void onResume() { + super.onResume(); + refreshTitle(); + invalidateOptionsMenu(); + DirectShareUtil.triggerRefreshDirectShare(this); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + if (isRelayingMessageContent(this)) { + inflater.inflate(R.menu.forwarding_menu, menu); + menu.findItem(R.id.menu_export_attachment).setVisible( + ShareUtil.isFromWebxdc(this) && ShareUtil.getSharedUris(this).size() == 1 + ); + } else { + inflater.inflate(R.menu.text_secure_normal, menu); + menu.findItem(R.id.menu_global_map).setVisible(Prefs.isLocationStreamingEnabled(this)); + MenuItem proxyItem = menu.findItem(R.id.menu_proxy_settings); + if (TextUtils.isEmpty(DcHelper.get(this, CONFIG_PROXY_URL))) { + proxyItem.setVisible(false); + } else { + boolean proxyEnabled = DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1; + proxyItem.setIcon(proxyEnabled? R.drawable.ic_proxy_enabled_24 : R.drawable.ic_proxy_disabled_24); + proxyItem.setVisible(true); + } + MenuCompat.setGroupDividerEnabled(menu, true); + } + + super.onPrepareOptionsMenu(menu); + return true; + } + + private void initializeSearchListener() { + searchAction.setOnClickListener(v -> { + searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), + searchAction.getY() + (searchAction.getHeight() / 2)); + }); + + searchToolbar.setListener(new SearchToolbar.SearchListener() { + @Override + public void onSearchTextChange(String text) { + String trimmed = text.trim(); + + if (trimmed.length() > 0) { + if (searchFragment == null) { + searchFragment = SearchFragment.newInstance(); + getSupportFragmentManager().beginTransaction() + .add(R.id.fragment_container, searchFragment, null) + .commit(); + } + searchFragment.updateSearchQuery(trimmed); + } else if (searchFragment != null) { + getSupportFragmentManager().beginTransaction() + .remove(searchFragment) + .commit(); + searchFragment = null; + } + } + + @Override + public void onSearchClosed() { + if (searchFragment != null) { + getSupportFragmentManager().beginTransaction() + .remove(searchFragment) + .commit(); + searchFragment = null; + } + } + }); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == R.id.menu_invite_friends) { + shareInvite(); + return true; + } else if (itemId == R.id.menu_settings) { + startActivity(new Intent(this, ApplicationPreferencesActivity.class)); + return true; + } else if (itemId == R.id.menu_qr) { + new IntentIntegrator(this).setCaptureActivity(QrActivity.class).initiateScan(); + return true; + } else if (itemId == R.id.menu_global_map) { + WebxdcActivity.openMaps(this, 0); + return true; + } else if (itemId == R.id.menu_proxy_settings) { + startActivity(new Intent(this, ProxySettingsActivity.class)); + return true; + } else if (itemId == android.R.id.home) { + onBackPressed(); + return true; + } else if (itemId == R.id.menu_all_media) { + startActivity(new Intent(this, AllMediaActivity.class)); + return true; + } else if (itemId == R.id.menu_export_attachment) { + handleSaveAttachment(); + } + + return false; + } + + private void handleSaveAttachment() { + SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore(this)) { + performSave(); + return; + } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .alwaysGrantOnSdk30() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(this::performSave) + .execute(); + }); + } + + private void performSave() { + ArrayList uriList = ShareUtil.getSharedUris(this); + Uri uri = uriList.get(0); + String mimeType = PersistentBlobProvider.getMimeType(this, uri); + String fileName = PersistentBlobProvider.getFileName(this, uri); + SaveAttachmentTask.Attachment[] attachments = new SaveAttachmentTask.Attachment[]{ + new SaveAttachmentTask.Attachment(uri, mimeType, new Date().getTime(), fileName) + }; + SaveAttachmentTask saveTask = new SaveAttachmentTask(this); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments); + onBackPressed(); + } + + private void handleOpenpgp4fpr() { + if (getIntent() != null && + Intent.ACTION_VIEW.equals(getIntent().getAction())) { + Uri uri = getIntent().getData(); + if (uri == null) { + return; + } + + if (uri.getScheme().equalsIgnoreCase(OPENPGP4FPR) || Util.isInviteURL(uri)) { + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.handleQrData(uri.toString()); + } + } + } + + private void handleResetRelaying() { + resetRelayingMessageContent(this); + refreshTitle(); + selfAvatarContainer.setVisibility(View.VISIBLE); + conversationListFragment.onNewIntent(); + invalidateOptionsMenu(); + } + + @Override + public void onCreateConversation(int chatId) { + openConversation(chatId, -1); + } + + public void openConversation(int chatId, int startingPosition) { + searchToolbar.clearFocus(); + + final DcContext dcContext = DcHelper.getContext(this); + if (isForwarding(this) && dcContext.getChat(chatId).isSelfTalk()) { + SendRelayedMessageUtil.immediatelyRelay(this, chatId); + Toast.makeText(this, DynamicTheme.getCheckmarkEmoji(this) + " " + getString(R.string.saved), Toast.LENGTH_SHORT).show(); + handleResetRelaying(); + finish(); + } else { + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(CHAT_ID_EXTRA, chatId); + intent.putExtra(STARTING_POSITION_EXTRA, startingPosition); + if (isRelayingMessageContent(this)) { + acquireRelayMessageContent(this, intent); + } + startActivity(intent); + + overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out); + } + } + + @Override + public void onSwitchToArchive() { + Intent intent = new Intent(this, ConversationListArchiveActivity.class); + if (isRelayingMessageContent(this)) { + acquireRelayMessageContent(this, intent); + } + startActivity(intent); + overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out); + } + + @Override + public void onBackPressed() { + if (searchToolbar.isVisible()) searchToolbar.collapse(); + else if (isRelayingMessageContent(this)) { + handleResetRelaying(); + finish(); + } else super.onBackPressed(); + } + + private void createChat() { + Intent intent = new Intent(this, NewConversationActivity.class); + if (isRelayingMessageContent(this)) { + acquireRelayMessageContent(this, intent); + } + startActivity(intent); + } + + private void shareInvite() { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + String inviteURL = DcHelper.getContext(this).getSecurejoinQr(0); + intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.invite_friends_text, inviteURL)); + startActivity(Intent.createChooser(intent, getString(R.string.chat_share_with_title))); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case IntentIntegrator.REQUEST_CODE: + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.onScanPerformed(scanResult); + break; + default: + break; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..3cfe66cce4a87f7ac23bbbf9b8837315dec549e6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListAdapter.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.ref.WeakReference; + +/** + * A CursorAdapter for building a list of conversation threads. + * + * @author Moxie Marlinspike + */ +class ConversationListAdapter extends BaseConversationListAdapter { + + private static final int MESSAGE_TYPE_SWITCH_ARCHIVE = 1; + private static final int MESSAGE_TYPE_THREAD = 2; + private static final int MESSAGE_TYPE_INBOX_ZERO = 3; + + private final WeakReference context; + private @NonNull DcContext dcContext; + private @NonNull DcChatlist dcChatlist; + private final @NonNull GlideRequests glideRequests; + private final @NonNull LayoutInflater inflater; + private final @Nullable ItemClickListener clickListener; + + protected static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(final @NonNull V itemView) + { + super(itemView); + } + + public BindableConversationListItem getItem() { + return (BindableConversationListItem)itemView; + } + } + + @Override + public int getItemCount() { + return dcChatlist.getCnt(); + } + + @Override + public long getItemId(int i) { + return dcChatlist.getChatId(i); + } + + ConversationListAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @Nullable ItemClickListener clickListener) + { + super(); + this.context = new WeakReference<>(context); + this.glideRequests = glideRequests; + this.dcContext = DcHelper.getContext(context); + this.dcChatlist = new DcChatlist(0, 0); + this.inflater = LayoutInflater.from(context); + this.clickListener = clickListener; + setHasStableIds(true); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == MESSAGE_TYPE_SWITCH_ARCHIVE) { + final ConversationListItem item = (ConversationListItem)inflater.inflate(R.layout.conversation_list_item_view, parent, false); + item.getLayoutParams().height = ViewUtil.dpToPx(54); + item.findViewById(R.id.subject).setVisibility(View.GONE); + item.findViewById(R.id.date).setVisibility(View.GONE); + item.setOnClickListener(v -> { + if (clickListener != null) clickListener.onSwitchToArchive(); + }); + + return new ViewHolder(item); + } else if (viewType == MESSAGE_TYPE_INBOX_ZERO) { + return new ViewHolder((ConversationListItemInboxZero)inflater.inflate(R.layout.conversation_list_item_inbox_zero, parent, false)); + } else { + final ConversationListItem item = (ConversationListItem)inflater.inflate(R.layout.conversation_list_item_view, + parent, false); + + item.setOnClickListener(view -> { + if (clickListener != null) clickListener.onItemClick(item); + }); + + item.setOnLongClickListener(view -> { + if (clickListener != null) clickListener.onItemLongClick(item); + return true; + }); + + return new ViewHolder(item); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder viewHolder, int i) { + Context context = this.context.get(); + if (context == null) { + return; + } + + DcChat chat = dcContext.getChat(dcChatlist.getChatId(i)); + DcLot summary = dcChatlist.getSummary(i, chat); + viewHolder.getItem().bind(DcHelper.getThreadRecord(context, summary, chat), dcChatlist.getMsgId(i), summary, glideRequests, batchSet, batchMode); + } + + @Override + public int getItemViewType(int i) { + int chatId = dcChatlist.getChatId(i); + + if (chatId == DcChat.DC_CHAT_ID_ARCHIVED_LINK) { + return MESSAGE_TYPE_SWITCH_ARCHIVE; + } else if(chatId == DcChat.DC_CHAT_ID_ALLDONE_HINT) { + return MESSAGE_TYPE_INBOX_ZERO; + } else { + return MESSAGE_TYPE_THREAD; + } + } + + @Override + public void selectAllThreads() { + for (int i = 0; i < dcChatlist.getCnt(); i++) { + long threadId = dcChatlist.getChatId(i); + if (threadId > DcChat.DC_CHAT_ID_LAST_SPECIAL) { + batchSet.add(threadId); + } + } + notifyDataSetChanged(); + } + + interface ItemClickListener { + void onItemClick(ConversationListItem item); + void onItemLongClick(ConversationListItem item); + void onSwitchToArchive(); + } + + void changeData(@Nullable DcChatlist chatlist) { + Context context = this.context.get(); + if (context == null) { + return; + } + if (chatlist == null) { + dcChatlist = new DcChatlist(0, 0); + } else { + dcChatlist = chatlist; + dcContext = DcHelper.getContext(context); + } + notifyDataSetChanged(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListArchiveActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationListArchiveActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..d7491f24bfdcc107f3efdc602dbd0baa05172232 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListArchiveActivity.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.ConversationActivity.CHAT_ID_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.FROM_ARCHIVED_CHATS_EXTRA; +import static org.thoughtcrime.securesms.util.ShareUtil.acquireRelayMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.isSharing; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.b44t.messenger.DcChat; + +import org.thoughtcrime.securesms.connect.DcHelper; + +public class ConversationListArchiveActivity extends PassphraseRequiredActionBarActivity + implements ConversationListFragment.ConversationSelectedListener +{ + @Override + protected void onCreate(Bundle icicle, boolean ready) { + setContentView(R.layout.activity_conversation_list_archive); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + if (isRelayingMessageContent(this)) { + getSupportActionBar().setTitle(isSharing(this) ? R.string.chat_share_with_title : R.string.forward_to); + getSupportActionBar().setSubtitle(R.string.chat_archived_label); + } else { + getSupportActionBar().setTitle(R.string.chat_archived_label); + } + + Bundle bundle = new Bundle(); + bundle.putBoolean(ConversationListFragment.ARCHIVE, true); + initFragment(R.id.fragment, new ConversationListFragment(), bundle); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + protected void onPause() { + super.onPause(); + if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.archived_list, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + onBackPressed(); + return true; + } else if (itemId == R.id.mark_as_read) { + DcHelper.getContext(this).marknoticedChat(DcChat.DC_CHAT_ID_ARCHIVED_LINK); + return true; + } + + return false; + } + + @Override + public void onBackPressed() { + if (isRelayingMessageContent(this)) { + // Go back to the ConversationListRelayingActivity + super.onBackPressed(); + } else { + // Load the ConversationListActivity in case it's not existent for some reason + Intent intent = new Intent(this, ConversationListActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + finish(); + } + } + + @Override + public void onCreateConversation(int chatId) { + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(CHAT_ID_EXTRA, chatId); + intent.putExtra(FROM_ARCHIVED_CHATS_EXTRA, true); + if (isRelayingMessageContent(this)) { + acquireRelayMessageContent(this, intent); + + // Just finish instead of updating the title and so on. This is not user-visible + // because the ConversationActivity will restart the ConversationListArchiveActivity + // after the user left. + finish(); + } + startActivity(intent); + + overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out); + } + + @Override + public void onSwitchToArchive() { + throw new AssertionError(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationListFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..7d22831d1c05a1fb259758049d31d3a3973d7e39 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListFragment.java @@ -0,0 +1,358 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ConversationListAdapter.ItemClickListener; +import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator; +import org.thoughtcrime.securesms.components.reminder.DozeReminder; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Timer; +import java.util.TimerTask; + + +public class ConversationListFragment extends BaseConversationListFragment + implements ItemClickListener, DcEventCenter.DcEventDelegate { + public static final String ARCHIVE = "archive"; + public static final String RELOAD_LIST = "reload_list"; + + private static final String TAG = ConversationListFragment.class.getSimpleName(); + + private RecyclerView list; + private View emptyState; + private TextView emptySearch; + private final String queryFilter = ""; + private boolean archive; + private Timer reloadTimer; + private boolean chatlistJustLoaded; + private boolean reloadTimerInstantly; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + archive = getArguments().getBoolean(ARCHIVE, false); + + DcEventCenter eventCenter = DcHelper.getEventCenter(requireActivity()); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_MSGS_NOTICED, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_CHAT_DELETED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_DELIVERED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_FAILED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this); + eventCenter.addObserver(DcContext.DC_EVENT_REACTIONS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONNECTIVITY_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_SELFAVATAR_CHANGED, this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(requireActivity()).removeObservers(this); + } + + @SuppressLint("RestrictedApi") + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { + final View view = inflater.inflate(R.layout.conversation_list_fragment, container, false); + + list = ViewUtil.findById(view, R.id.list); + fab = ViewUtil.findById(view, R.id.fab); + emptyState = ViewUtil.findById(view, R.id.empty_state); + emptySearch = ViewUtil.findById(view, R.id.empty_search); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(list, true, archive, true, true); + + if (archive) { + fab.setVisibility(View.GONE); + TextView emptyTitle = ViewUtil.findById(view, R.id.empty_title); + emptyTitle.setText(R.string.archive_empty_hint); + } else { + fab.setVisibility(View.VISIBLE); + } + // Apply insets to prevent fab from being covered by system bars + ViewUtil.applyWindowInsetsAsMargin(fab); + + list.setHasFixedSize(true); + list.setLayoutManager(new LinearLayoutManager(getActivity())); + list.setItemAnimator(new DeleteItemAnimator()); + + return view; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + setHasOptionsMenu(true); + initializeFabClickListener(false); + list.setAdapter(new ConversationListAdapter(requireActivity(), GlideApp.with(this), this)); + loadChatlistAsync(); + chatlistJustLoaded = true; + } + + @Override + public void onResume() { + super.onResume(); + + updateReminders(); + + if (requireActivity().getIntent().getIntExtra(RELOAD_LIST, 0) == 1 + && !chatlistJustLoaded) { + loadChatlist(); + reloadTimerInstantly = false; + } + chatlistJustLoaded = false; + + reloadTimer = new Timer(); + reloadTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + Util.runOnMain(() -> { + list.getAdapter().notifyDataSetChanged(); + }); + } + }, reloadTimerInstantly? 0 : 60 * 1000, 60 * 1000); + } + + @Override + public void onPause() { + super.onPause(); + + reloadTimer.cancel(); + reloadTimerInstantly = true; + + fab.stopPulse(); + } + + public void onNewIntent() { + initializeFabClickListener(actionMode != null); + } + + @Override + public BaseConversationListAdapter getListAdapter() { + return (BaseConversationListAdapter) list.getAdapter(); + } + + @SuppressLint({"StaticFieldLeak", "NewApi"}) + private void updateReminders() { + // by the time onPostExecute() is asynchronously run, getActivity() might return null, so get the activity here: + Activity activity = requireActivity(); + new AsyncTask() { + @Override + protected Void doInBackground(Context... params) { + final Context context = params[0]; + try { + if (DozeReminder.isEligible(context)) { + DozeReminder.addDozeReminderDeviceMsg(context); + } + FcmReceiveService.waitForRegisterFinished(); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!Prefs.getBooleanPreference(activity, Prefs.ASKED_FOR_NOTIFICATION_PERMISSION, false)) { + Prefs.setBooleanPreference(activity, Prefs.ASKED_FOR_NOTIFICATION_PERMISSION, true); + Permissions.with(activity) + .request(Manifest.permission.POST_NOTIFICATIONS) + .ifNecessary() + .onAllGranted(() -> { + DozeReminder.maybeAskDirectly(activity); + }) + .onAnyDenied(() -> { + final DcContext dcContext = DcHelper.getContext(activity); + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + msg.setText("\uD83D\uDC49 "+activity.getString(R.string.notifications_disabled)+" \uD83D\uDC48\n\n" + +activity.getString(R.string.perm_explain_access_to_notifications_denied)); + dcContext.addDeviceMsg("android.notifications-disabled", msg); + }) + .execute(); + } else { + DozeReminder.maybeAskDirectly(activity); + } + } else { + DozeReminder.maybeAskDirectly(activity); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, activity); + } + + private final Object loadChatlistLock = new Object(); + private boolean inLoadChatlist; + private boolean needsAnotherLoad; + private void loadChatlistAsync() { + synchronized (loadChatlistLock) { + needsAnotherLoad = true; + if (inLoadChatlist) { + Log.i(TAG, "chatlist loading debounced"); + return; + } + inLoadChatlist = true; + } + + Util.runOnAnyBackgroundThread(() -> { + while(true) { + synchronized (loadChatlistLock) { + if (!needsAnotherLoad) { + inLoadChatlist = false; + return; + } + needsAnotherLoad = false; + } + + Log.i(TAG, "executing debounced chatlist loading"); + loadChatlist(); + Util.sleep(100); + } + }); + } + + private void loadChatlist() { + int listflags = 0; + if (archive) { + listflags |= DcContext.DC_GCL_ARCHIVED_ONLY; + } else if (ShareUtil.isRelayingMessageContent(getActivity())) { + listflags |= DcContext.DC_GCL_FOR_FORWARDING; + } else { + listflags |= DcContext.DC_GCL_ADD_ALLDONE_HINT; + } + + Context context = getContext(); + if (context == null) { + // can't load chat list at this time, see: https://github.com/deltachat/deltachat-android/issues/2012 + Log.w(TAG, "Ignoring call to loadChatlist()"); + return; + } + DcChatlist chatlist = DcHelper.getContext(context).getChatlist(listflags, queryFilter.isEmpty() ? null : queryFilter, 0); + + Util.runOnMain(() -> { + if (chatlist.getCnt() <= 0 && TextUtils.isEmpty(queryFilter)) { + list.setVisibility(View.INVISIBLE); + emptyState.setVisibility(View.VISIBLE); + emptySearch.setVisibility(View.INVISIBLE); + fab.startPulse(3 * 1000); + } else if (chatlist.getCnt() <= 0 && !TextUtils.isEmpty(queryFilter)) { + list.setVisibility(View.INVISIBLE); + emptyState.setVisibility(View.GONE); + emptySearch.setVisibility(View.VISIBLE); + emptySearch.setText(getString(R.string.search_no_result_for_x, queryFilter)); + } else { + list.setVisibility(View.VISIBLE); + emptyState.setVisibility(View.GONE); + emptySearch.setVisibility(View.INVISIBLE); + fab.stopPulse(); + } + + ((ConversationListAdapter)list.getAdapter()).changeData(chatlist); + }); + } + + @Override + protected boolean offerToArchive() { + return !archive; + } + + @Override + protected void setFabVisibility(boolean isActionMode) { + fab.setVisibility((isActionMode || !archive)? View.VISIBLE : View.GONE); + } + + @Override + public void onItemClick(ConversationListItem item) { + onItemClick(item.getChatId()); + } + + @Override + public void onItemLongClick(ConversationListItem item) { + onItemLongClick(item.getChatId()); + } + + @Override + public void onSwitchToArchive() { + ((ConversationSelectedListener)requireActivity()).onSwitchToArchive(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + final int accId = event.getAccountId(); + if (event.getId() == DcContext.DC_EVENT_CHAT_DELETED) { + DcHelper.getNotificationCenter(requireActivity()).removeNotifications(accId, event.getData1Int()); + } else if (accId != DcHelper.getContext(requireActivity()).getAccountId()) { + Activity activity = getActivity(); + if (activity instanceof ConversationListActivity) { + ((ConversationListActivity) activity).refreshUnreadIndicator(); + } + + } else if (event.getId() == DcContext.DC_EVENT_CONNECTIVITY_CHANGED) { + Activity activity = getActivity(); + if (activity instanceof ConversationListActivity) { + ((ConversationListActivity) activity).refreshTitle(); + } + + } else if (event.getId() == DcContext.DC_EVENT_SELFAVATAR_CHANGED) { + Activity activity = getActivity(); + if (activity instanceof ConversationListActivity) { + ((ConversationListActivity) activity).refreshAvatar(); + } + + } else { + loadChatlistAsync(); + } + } + +} + + diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java new file mode 100644 index 0000000000000000000000000000000000000000..a7a93292589a989051b3c7d535d84366a08bc0f6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.components.DeliveryStatusView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Collections; +import java.util.Set; + +public class ConversationListItem extends RelativeLayout + implements BindableConversationListItem, Unbindable +{ + private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); + private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); + + private Set selectedThreads; + private long chatId; + private int msgId; + private TextView subjectView; + private FromTextView fromView; + private TextView dateView; + private TextView archivedBadgeView; + private TextView requestBadgeView; + private DeliveryStatusView deliveryStatusIndicator; + private ImageView unreadIndicator; + + private AvatarView avatar; + + public ConversationListItem(Context context) { + this(context, null); + } + + public ConversationListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.subjectView = findViewById(R.id.subject); + this.fromView = findViewById(R.id.from_text); + this.dateView = findViewById(R.id.date); + this.deliveryStatusIndicator = new DeliveryStatusView(findViewById(R.id.delivery_indicator)); + this.avatar = findViewById(R.id.avatar); + this.archivedBadgeView = findViewById(R.id.archived_badge); + this.requestBadgeView = findViewById(R.id.request_badge); + this.unreadIndicator = findViewById(R.id.unread_indicator); + + ViewUtil.setTextViewGravityStart(this.fromView, getContext()); + ViewUtil.setTextViewGravityStart(this.subjectView, getContext()); + } + + @Override + public void bind(@NonNull ThreadRecord thread, + int msgId, + @NonNull DcLot dcSummary, + @NonNull GlideRequests glideRequests, + @NonNull Set selectedThreads, + boolean batchMode) + { + bind(thread, msgId, dcSummary, glideRequests, selectedThreads, batchMode, null); + } + + public void bind(@NonNull ThreadRecord thread, + int msgId, + @NonNull DcLot dcSummary, + @NonNull GlideRequests glideRequests, + @NonNull Set selectedThreads, + boolean batchMode, + @Nullable String highlightSubstring) + { + this.selectedThreads = selectedThreads; + Recipient recipient = thread.getRecipient(); + this.chatId = thread.getThreadId(); + this.msgId = msgId; + + int state = dcSummary.getState(); + int unreadCount = thread.getUnreadCount(); + + if (highlightSubstring != null) { + this.fromView.setText(getHighlightedSpan(recipient.getName(), highlightSubstring)); + } else { + this.fromView.setText(recipient, state!=DcMsg.DC_STATE_IN_FRESH); + } + + subjectView.setVisibility(chatId == DcChat.DC_CHAT_ID_ARCHIVED_LINK? GONE : VISIBLE); + this.subjectView.setText(thread.getDisplayBody()); + this.subjectView.setTypeface(state==DcMsg.DC_STATE_IN_FRESH ? BOLD_TYPEFACE : LIGHT_TYPEFACE); + this.subjectView.setTextColor(state==DcMsg.DC_STATE_IN_FRESH ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color) + : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)); + + if (thread.getDate() > 0) { + CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), thread.getDate()); + dateView.setText(date); + } + else { + dateView.setText(""); + } + + dateView.setCompoundDrawablesWithIntrinsicBounds( + thread.isSendingLocations()? R.drawable.ic_location_chatlist : 0, 0, + thread.getVisibility()==DcChat.DC_CHAT_VISIBILITY_PINNED? R.drawable.ic_pinned_chatlist : 0, 0 + ); + + setStatusIcons(thread.getVisibility(), state, unreadCount, thread.isContactRequest(), thread.isMuted() || chatId == DcChat.DC_CHAT_ID_ARCHIVED_LINK); + setBatchState(batchMode); + setBgColor(thread); + + this.avatar.setAvatar(glideRequests, recipient, false); + + DcContact contact = recipient.getDcContact(); + avatar.setSeenRecently(contact != null && contact.wasSeenRecently()); + + DcChat dcChat = DcHelper.getContext(getContext()).getChat((int)chatId); + boolean isProtected = dcChat.isDeviceTalk() || dcChat.isSelfTalk(); + + fromView.setCompoundDrawablesWithIntrinsicBounds( + thread.isMuted()? R.drawable.ic_volume_off_grey600_18dp : 0, + 0, + isProtected? R.drawable.ic_verified : 0, + 0); + } + + public void bind(@NonNull DcContact contact, + @NonNull GlideRequests glideRequests, + @Nullable String highlightSubstring) + { + this.selectedThreads = Collections.emptySet(); + Recipient recipient = new Recipient(getContext(), contact); + + fromView.setText(getHighlightedSpan(contact.getDisplayName(), highlightSubstring)); + fromView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + subjectView.setVisibility(GONE); + dateView.setText(""); + dateView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + archivedBadgeView.setVisibility(GONE); + requestBadgeView.setVisibility(GONE); + unreadIndicator.setVisibility(GONE); + deliveryStatusIndicator.setNone(); + + setBatchState(false); + avatar.setAvatar(glideRequests, recipient, false); + avatar.setSeenRecently(contact.wasSeenRecently()); + } + + public void bind(@NonNull DcMsg messageResult, + @NonNull GlideRequests glideRequests, + @Nullable String highlightSubstring) + { + DcContext dcContext = DcHelper.getContext(getContext()); + DcContact sender = dcContext.getContact(messageResult.getFromId()); + this.selectedThreads = Collections.emptySet(); + Recipient recipient = new Recipient(getContext(), sender); + + fromView.setText(recipient, true); + fromView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + subjectView.setVisibility(VISIBLE); + subjectView.setText(getHighlightedSpan(messageResult.getSummarytext(512), highlightSubstring)); + + long timestamp = messageResult.getTimestamp(); + if(timestamp>0) { + dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), messageResult.getTimestamp())); + } + else { + dateView.setText(""); + } + dateView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + archivedBadgeView.setVisibility(GONE); + requestBadgeView.setVisibility(GONE); + unreadIndicator.setVisibility(GONE); + deliveryStatusIndicator.setNone(); + + setBatchState(false); + avatar.setAvatar(glideRequests, recipient, false); + avatar.setSeenRecently(false); + } + + @Override + public void unbind() { + } + + private void setBatchState(boolean batch) { + setSelected(batch && selectedThreads.contains(chatId)); + } + + public long getChatId() { + return chatId; + } + + public int getMsgId() { + return msgId; + } + + private void setStatusIcons(int visibility, int state, int unreadCount, boolean isContactRequest, boolean isMuted) { + if (visibility==DcChat.DC_CHAT_VISIBILITY_ARCHIVED) + { + archivedBadgeView.setVisibility(View.VISIBLE); + requestBadgeView.setVisibility(isContactRequest ? View.VISIBLE : View.GONE); + deliveryStatusIndicator.setNone(); + } + else if (isContactRequest) { + requestBadgeView.setVisibility(View.VISIBLE); + archivedBadgeView.setVisibility(View.GONE); + deliveryStatusIndicator.setNone(); + } + else + { + requestBadgeView.setVisibility(View.GONE); + archivedBadgeView.setVisibility(View.GONE); + + if (state == DcMsg.DC_STATE_OUT_FAILED) { + deliveryStatusIndicator.setFailed(); + } else if (state == DcMsg.DC_STATE_OUT_MDN_RCVD) { + deliveryStatusIndicator.setRead(); + } else if (state == DcMsg.DC_STATE_OUT_DELIVERED) { + deliveryStatusIndicator.setSent(); + } else if (state == DcMsg.DC_STATE_OUT_PREPARING) { + deliveryStatusIndicator.setPreparing(); + } else if (state == DcMsg.DC_STATE_OUT_PENDING) { + deliveryStatusIndicator.setPending(); + } else { + deliveryStatusIndicator.setNone(); + } + + if (state == DcMsg.DC_STATE_OUT_FAILED) { + deliveryStatusIndicator.setTint(Color.RED); + } else { + deliveryStatusIndicator.resetTint(); + } + } + + if(unreadCount==0 || isContactRequest) { + unreadIndicator.setVisibility(View.GONE); + } else { + final int color; + if(isMuted){ + color = getResources().getColor(ThemeUtil.isDarkTheme(getContext()) ? R.color.unread_count_muted_dark : R.color.unread_count_muted); + } else { + final TypedArray attrs = getContext().obtainStyledAttributes(new int[] { + R.attr.conversation_list_item_unreadcount_color, + }); + color = attrs.getColor(0, Color.BLACK); + } + unreadIndicator.setImageDrawable(TextDrawable.builder() + .beginConfig() + .width(ViewUtil.dpToPx(getContext(), 24)) + .height(ViewUtil.dpToPx(getContext(), 24)) + .textColor(Color.WHITE) + .bold() + .endConfig() + .buildRound(String.valueOf(unreadCount), color)); + unreadIndicator.setVisibility(View.VISIBLE); + } + } + + private void setBgColor(ThreadRecord thread) { + int bg = R.attr.conversation_list_item_background; + if (thread!=null && thread.getVisibility()==DcChat.DC_CHAT_VISIBILITY_PINNED) { + bg = R.attr.pinned_list_item_background; + } + try (TypedArray ta = getContext().obtainStyledAttributes(new int[]{bg})) { + ViewUtil.setBackground(this, ta.getDrawable(0)); + } + } + + private Spanned getHighlightedSpan(@Nullable String value, + @Nullable String highlight) + { + if (TextUtils.isEmpty(value)) { + return new SpannableString(""); + } + + value = value.replaceAll("\n", " "); + + if (TextUtils.isEmpty(highlight)) { + return new SpannableString(value); + } + + String normalizedValue = value.toLowerCase(Util.getLocale()); + String normalizedTest = highlight.toLowerCase(Util.getLocale()); + + Spannable spanned = new SpannableString(value); + int searchStartIndex = 0; + + for (String token : normalizedTest.split(" ")) { + if (token.trim().isEmpty()) continue; + if (searchStartIndex >= spanned.length()) { + break; + } + + int start = normalizedValue.indexOf(token, searchStartIndex); + + if (start >= 0) { + int end = Math.min(start + token.length(), spanned.length()); + spanned.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + searchStartIndex = end; + } + } + + return spanned; + } + + public void hideItemDivider() { + View itemDivider = findViewById(R.id.item_divider); + itemDivider.setVisibility(View.GONE); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListItemInboxZero.java b/src/main/java/org/thoughtcrime/securesms/ConversationListItemInboxZero.java new file mode 100644 index 0000000000000000000000000000000000000000..76a146d840f4d9244e3ca9f998517f7a3fc9d0e3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListItemInboxZero.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms; + + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcLot; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.Set; + +public class ConversationListItemInboxZero extends LinearLayout implements BindableConversationListItem{ + public ConversationListItemInboxZero(Context context) { + super(context); + } + + public ConversationListItemInboxZero(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ConversationListItemInboxZero(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ConversationListItemInboxZero(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public void unbind() { + + } + + @Override + public void bind(@NonNull ThreadRecord thread, int msgId, @NonNull DcLot dcSummary, @NonNull GlideRequests glideRequests, @NonNull Set selectedThreads, boolean batchMode) { + + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListRelayingActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationListRelayingActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..369bcf1d96a7225788b1748156e291234a6f23b5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListRelayingActivity.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.os.Bundle; + +import androidx.fragment.app.Fragment; + +import java.lang.ref.WeakReference; + +/** + * "Relaying" means "Forwarding or Sharing". + * + * When forwarding or sharing, we show the ConversationListActivity to the user. + * However, ConversationListActivity has `launchMode="singleTask"`, which means that this will + * destroy the existing ConversationListActivity. + * + * In API 20-29, `startActivityForResult()` could be used instead of `startActivity()` + * to override this behavior and get two instances of ConversationListActivity. + * + * As this is not possible anymore starting with API 30, we needed another solution, and created + * this activity here. + * + * See https://github.com/deltachat/deltachat-android/issues/1704. + */ + +public class ConversationListRelayingActivity extends ConversationListActivity { + static WeakReference INSTANCE = null; + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + super.onCreate(icicle, ready); + INSTANCE = new WeakReference<>(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + INSTANCE = null; + } + + // =================== Static Methods =================== + public static void start(Fragment fragment, Intent intent) { + intent.setComponent(new ComponentName(fragment.getContext(), ConversationListRelayingActivity.class)); + fragment.startActivity(intent); + } + + public static void start(Activity activity, Intent intent) { + intent.setComponent(new ComponentName(activity, ConversationListRelayingActivity.class)); + activity.startActivity(intent); + } + + public static void finishActivity() { + if (INSTANCE != null && INSTANCE.get() != null) { + INSTANCE.get().finish(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationSwipeAnimationHelper.java b/src/main/java/org/thoughtcrime/securesms/ConversationSwipeAnimationHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..2e69f4ba2b701bc3d5d794e1055fa3ca32d50820 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationSwipeAnimationHelper.java @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms; + +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.view.View; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.Util; + +final class ConversationSwipeAnimationHelper { + + static final float TRIGGER_DX = dpToPx(64); + static final float MAX_DX = dpToPx(96); + + private static final float REPLY_SCALE_OVERSHOOT = 1.8f; + private static final float REPLY_SCALE_MAX = 1.2f; + private static final float REPLY_SCALE_MIN = 1f; + private static final long REPLY_SCALE_OVERSHOOT_DURATION = 200; + + private static final Interpolator BUBBLE_INTERPOLATOR = new BubblePositionInterpolator(0f, TRIGGER_DX, MAX_DX); + private static final Interpolator REPLY_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(0f, 1f, 1f); + private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10)); + private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8)); + private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX); + + private ConversationSwipeAnimationHelper() { + } + + public static void update(@NonNull ConversationItem conversationItem, float dx, float sign) { + float progress = dx / TRIGGER_DX; + + updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign); + updateReactionsTransition(conversationItem.reactionsView, dx, sign); + updateReplyIconTransition(conversationItem.replyView, dx, progress, sign); + updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign); + } + + public static void trigger(@NonNull ConversationItem conversationItem) { + triggerReplyIcon(conversationItem.replyView); + } + + private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float dx, float sign) { + bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } + + private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) { + reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } + + private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) { + if (progress > 0.05f) { + replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress)); + } else replyIcon.setAlpha(0f); + + replyIcon.setTranslationX(REPLY_TRANSITION_INTERPOLATOR.getInterpolation(progress) * sign); + + if (dx < TRIGGER_DX) { + float scale = REPLY_SCALE_INTERPOLATOR.getInterpolation(progress); + replyIcon.setScaleX(scale); + replyIcon.setScaleY(scale); + } + } + + private static void updateContactPhotoHolderTransition(@Nullable View contactPhotoHolder, + float progress, + float sign) + { + if (contactPhotoHolder == null) return; + contactPhotoHolder.setTranslationX(AVATAR_INTERPOLATOR.getInterpolation(progress) * sign); + } + + private static void triggerReplyIcon(@NonNull View replyIcon) { + ValueAnimator animator = ValueAnimator.ofFloat(REPLY_SCALE_MAX, REPLY_SCALE_OVERSHOOT, REPLY_SCALE_MAX); + animator.setDuration(REPLY_SCALE_OVERSHOOT_DURATION); + animator.addUpdateListener(animation -> { + replyIcon.setScaleX((float) animation.getAnimatedValue()); + replyIcon.setScaleY((float) animation.getAnimatedValue()); + }); + animator.start(); + } + + private static int dpToPx(int dp) { + return (int) (dp * Resources.getSystem().getDisplayMetrics().density); + } + + private static final class BubblePositionInterpolator implements Interpolator { + + private final float start; + private final float middle; + private final float end; + + private BubblePositionInterpolator(float start, float middle, float end) { + this.start = start; + this.middle = middle; + this.end = end; + } + + @Override + public float getInterpolation(float input) { + if (input < start) { + return start; + } else if (input < middle) { + return input; + } else { + float segmentLength = end - middle; + float segmentTraveled = input - middle; + float segmentCompletion = segmentTraveled / segmentLength; + float scaleDownFactor = middle / (input * 2); + float output = middle + (segmentLength * segmentCompletion * scaleDownFactor); + + return Math.min(output, end); + } + } + } + + private static final class ClampingLinearInterpolator implements Interpolator { + + private final float slope; + private final float yIntercept; + private final float max; + private final float min; + + ClampingLinearInterpolator(float start, float end) { + this(start, end, 1.0f); + } + + ClampingLinearInterpolator(float start, float end, float scale) { + slope = (end - start) * scale; + yIntercept = start; + max = Math.max(start, end); + min = Math.min(start, end); + } + + @Override + public float getInterpolation(float input) { + return Util.clamp(slope * input + yIntercept, min, max); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationTitleView.java b/src/main/java/org/thoughtcrime/securesms/ConversationTitleView.java new file mode 100644 index 0000000000000000000000000000000000000000..b1c3a7203d01ac2461b9b501c49cd0ea22fe72a1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationTitleView.java @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Locale; + +public class ConversationTitleView extends RelativeLayout { + + private View content; + private ImageView back; + private AvatarView avatar; + private TextView title; + private TextView subtitle; + private ImageView ephemeralIcon; + + public ConversationTitleView(Context context) { + this(context, null); + } + + public ConversationTitleView(Context context, AttributeSet attrs) { + super(context, attrs); + + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + this.back = ViewUtil.findById(this, R.id.up_button); + this.content = ViewUtil.findById(this, R.id.content); + this.title = ViewUtil.findById(this, R.id.title); + this.subtitle = ViewUtil.findById(this, R.id.subtitle); + this.avatar = ViewUtil.findById(this, R.id.avatar); + this.ephemeralIcon = ViewUtil.findById(this, R.id.ephemeral_icon); + + ViewUtil.setTextViewGravityStart(this.title, getContext()); + ViewUtil.setTextViewGravityStart(this.subtitle, getContext()); + } + + public void setTitle(@NonNull GlideRequests glideRequests, @NonNull DcChat dcChat) { + final int chatId = dcChat.getId(); + final Context context = getContext(); + final DcContext dcContext = DcHelper.getContext(context); + + // set title and subtitle texts + title.setText(dcChat.getName()); + String subtitleStr = null; + + boolean isOnline = false; + int[] chatContacts = dcContext.getChatContacts(chatId); + if (dcChat.isMailingList()) { + subtitleStr = context.getString(R.string.mailing_list); + } else if (dcChat.isInBroadcast()) { + subtitleStr = context.getString(R.string.channel); + } else if (dcChat.isOutBroadcast()) { + subtitleStr = context.getResources().getQuantityString(R.plurals.n_recipients, chatContacts.length, chatContacts.length); + } else if( dcChat.isMultiUser() ) { + if (chatContacts.length > 1 || Util.contains(chatContacts, DcContact.DC_CONTACT_ID_SELF)) { + subtitleStr = context.getResources().getQuantityString(R.plurals.n_members, chatContacts.length, chatContacts.length); + } else { + subtitleStr = "…"; + } + } else if( chatContacts.length>=1 ) { + if( dcChat.isSelfTalk() ) { + subtitleStr = context.getString(R.string.chat_self_talk_subtitle); + } + else if( dcChat.isDeviceTalk() ) { + subtitleStr = context.getString(R.string.device_talk_subtitle); + } + else { + DcContact dcContact = dcContext.getContact(chatContacts[0]); + isOnline = dcContact.wasSeenRecently(); + if (!dcChat.isEncrypted()) { + subtitleStr = dcContact.getAddr(); + } else if (dcContact.isBot()) { + subtitleStr = context.getString(R.string.bot); + } else if (isOnline) { + subtitleStr = context.getString(R.string.online); + } else { + long timestamp = dcContact.getLastSeen(); + if (timestamp >= 0) { + subtitleStr = context.getString(R.string.last_seen_at, DateUtils.getExtendedTimeSpanString(context, timestamp)); + } + } + } + } + + avatar.setAvatar(glideRequests, new Recipient(getContext(), dcChat), false); + avatar.setSeenRecently(isOnline); + int imgLeft = dcChat.isMuted()? R.drawable.ic_volume_off_white_18dp : 0; + int imgRight = dcChat.isSelfTalk() || dcChat.isDeviceTalk()? R.drawable.ic_verified : 0; + title.setCompoundDrawablesWithIntrinsicBounds(imgLeft, 0, imgRight, 0); + if (!TextUtils.isEmpty(subtitleStr)) { + subtitle.setText(subtitleStr); + subtitle.setVisibility(View.VISIBLE); + } else { + subtitle.setVisibility(View.GONE); + } + boolean isEphemeral = dcContext.getChatEphemeralTimer(chatId) != 0; + ephemeralIcon.setVisibility(isEphemeral? View.VISIBLE : View.GONE); + } + + public void setTitle(@NonNull GlideRequests glideRequests, @NonNull DcContact contact) { + // This function is only called for contacts without a corresponding 1:1 chat. + // If there is a 1:1 chat, then the overloaded function + // setTitle(GlideRequests, DcChat, boolean) is called. + avatar.setAvatar(glideRequests, new Recipient(getContext(), contact), false); + avatar.setSeenRecently(contact.wasSeenRecently()); + + title.setText(contact.getDisplayName()); + subtitle.setText(contact.getAddr()); + subtitle.setVisibility(View.VISIBLE); + } + + public void setSeenRecently(boolean seenRecently) { + avatar.setSeenRecently(seenRecently); + } + + @Override + public void setOnClickListener(@Nullable OnClickListener listener) { + this.content.setOnClickListener(listener); + this.avatar.setAvatarClickListener(listener); + } + + public void setOnBackClickedListener(@Nullable OnClickListener listener) { + this.back.setOnClickListener(listener); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java new file mode 100644 index 0000000000000000000000000000000000000000..4e484a368e7183048af2d57a712a7c6c5586afb6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcMsg; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.components.DeliveryStatusView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.ByteArrayInputStream; +import java.util.Set; + +public class ConversationUpdateItem extends BaseConversationItem +{ + private DeliveryStatusView deliveryStatusView; + private AppCompatImageView appIcon; + private int textColor; + + public ConversationUpdateItem(Context context) { + this(context, null); + } + + public ConversationUpdateItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + initializeAttributes(); + + bodyText = findViewById(R.id.conversation_update_body); + deliveryStatusView = new DeliveryStatusView(findViewById(R.id.delivery_indicator)); + appIcon = findViewById(R.id.app_icon); + + + bodyText.setOnLongClickListener(passthroughClickListener); + bodyText.setOnClickListener(passthroughClickListener); + + // info messages do not contain links but domains (eg. invalid_unencrypted_tap_to_learn_more), + // however, they should not be linkified to not disturb eg. "Tap to learn more". + bodyText.setAutoLinkMask(0); + } + + @Override + public void bind(@NonNull DcMsg messageRecord, + @NonNull DcChat dcChat, + @NonNull GlideRequests glideRequests, + @NonNull Set batchSelected, + @NonNull Recipient conversationRecipient, + boolean pulseUpdate) + { + bind(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient); + setGenericInfoRecord(messageRecord); + } + + private void initializeAttributes() { + final int[] attributes = new int[] { + R.attr.conversation_item_update_text_color, + }; + final TypedArray attrs = context.obtainStyledAttributes(attributes); + + textColor = attrs.getColor(0, Color.WHITE); + attrs.recycle(); + } + + @Override + public void setEventListener(@Nullable EventListener listener) { + // No events to report yet + } + + @Override + public DcMsg getMessageRecord() { + return messageRecord; + } + + private void setGenericInfoRecord(DcMsg messageRecord) { + int infoType = messageRecord.getInfoType(); + + if (infoType == DcMsg.DC_INFO_WEBXDC_INFO_MESSAGE) { + DcMsg parentMsg = messageRecord.getParent(); + + // It is possible that we only received an update without the webxdc itself. + // In this case parentMsg is null and we display update message without the icon. + if (parentMsg != null) { + JSONObject info = parentMsg.getWebxdcInfo(); + byte[] blob = parentMsg.getWebxdcBlob(JsonUtils.optString(info, "icon")); + if (blob != null) { + ByteArrayInputStream is = new ByteArrayInputStream(blob); + Drawable drawable = Drawable.createFromStream(is, "icon"); + appIcon.setImageDrawable(drawable); + appIcon.setVisibility(VISIBLE); + } else { + appIcon.setVisibility(GONE); + } + } + } else { + appIcon.setVisibility(GONE); + } + + bodyText.setText(messageRecord.getDisplayBody()); + bodyText.setVisibility(VISIBLE); + + if (messageRecord.isFailed()) deliveryStatusView.setFailed(); + else if (!messageRecord.isOutgoing()) deliveryStatusView.setNone(); + else if (messageRecord.isPreparing()) deliveryStatusView.setPreparing(); + else if (messageRecord.isPending()) deliveryStatusView.setPending(); + else deliveryStatusView.setNone(); + + if (messageRecord.isFailed()) { + deliveryStatusView.setTint(Color.RED); + } else { + deliveryStatusView.setTint(textColor); + } + } + + @Override + public void unbind() { + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/CreateProfileActivity.java b/src/main/java/org/thoughtcrime/securesms/CreateProfileActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..85b9bb144c7f5583f141241883bba50a83ac1194 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/CreateProfileActivity.java @@ -0,0 +1,305 @@ +package org.thoughtcrime.securesms; + + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.loader.app.LoaderManager; + +import com.b44t.messenger.DcContext; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.SimpleTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.android.material.textfield.TextInputLayout; + +import org.thoughtcrime.securesms.components.AvatarSelector; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.File; +import java.io.IOException; +import java.security.SecureRandom; + + +@SuppressLint("StaticFieldLeak") +public class CreateProfileActivity extends BaseActionBarActivity { + + private static final String TAG = CreateProfileActivity.class.getSimpleName(); + + public static final String FROM_WELCOME = "from_welcome"; + + private static final int REQUEST_CODE_AVATAR = 1; + + private InputAwareLayout container; + private ImageView avatar; + private EditText name; + private EditText statusView; + + private boolean fromWelcome; + private boolean avatarChanged; + private boolean imageLoaded; + + private Bitmap avatarBmp; + private AttachmentManager attachmentManager; + + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + this.fromWelcome = getIntent().getBooleanExtra(FROM_WELCOME, false); + + setContentView(R.layout.profile_create_activity); + + getSupportActionBar().setTitle(R.string.pref_profile_info_headline); + getSupportActionBar().setDisplayHomeAsUpEnabled(!this.fromWelcome); + getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + + attachmentManager = new AttachmentManager(this, () -> {}); + avatarChanged = false; + initializeResources(); + initializeProfileName(); + initializeProfileAvatar(); + initializeStatusText(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + inflater.inflate(R.menu.preferences_create_profile_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + onBackPressed(); + return true; + } else if (itemId == R.id.menu_create_profile) { + updateProfile(); + } + + return false; + } + + @Override + public void onBackPressed() { + if (container.isInputOpen()) { + container.hideCurrentInput(name); + } else if (fromWelcome) { + updateProfile(); + } else { + super.onBackPressed(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode != Activity.RESULT_OK) { + return; + } + + switch (requestCode) { + case REQUEST_CODE_AVATAR: + Uri inputFile = (data != null ? data.getData() : null); + onFileSelected(inputFile); + break; + + case ScribbleActivity.SCRIBBLE_REQUEST_CODE: + setAvatarView(data.getData()); + break; + } + } + + private void setAvatarView(Uri output) { + GlideApp.with(this) + .asBitmap() + .load(output) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_SIZE, AvatarHelper.AVATAR_SIZE) + .into(new SimpleTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + avatarChanged = true; + imageLoaded = true; + avatarBmp = resource; + } + }); + GlideApp.with(this) + .load(output) + .circleCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatar); + } + + private void onFileSelected(Uri inputFile) { + if (inputFile == null) { + inputFile = attachmentManager.getImageCaptureUri(); + } + + AvatarHelper.cropAvatar(this, inputFile); + } + + private void initializeResources() { + TextView loginSuccessText = ViewUtil.findById(this, R.id.login_success_text); + this.avatar = ViewUtil.findById(this, R.id.avatar); + this.name = ViewUtil.findById(this, R.id.name_text); + this.container = ViewUtil.findById(this, R.id.container); + this.statusView = ViewUtil.findById(this, R.id.status_text); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(container); + + if (fromWelcome) { + loginSuccessText.setText(R.string.set_name_and_avatar_explain); + ViewUtil.findById(this, R.id.status_text_layout).setVisibility(View.GONE); + ViewUtil.findById(this, R.id.information_label).setVisibility(View.GONE); + } else { + loginSuccessText.setVisibility(View.GONE); + } + } + + private void initializeProfileName() { + String profileName = DcHelper.get(this, DcHelper.CONFIG_DISPLAY_NAME); + if (!TextUtils.isEmpty(profileName)) { + name.setText(profileName); + name.setSelection(profileName.length(), profileName.length()); + } + } + + private void initializeProfileAvatar() { + File avatarFile = AvatarHelper.getSelfAvatarFile(this); + if (avatarFile.exists() && avatarFile.length() > 0) { + imageLoaded = true; + GlideApp.with(this) + .load(avatarFile) + .circleCrop() + .into(avatar); + } else { + imageLoaded = false; + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_alt_white_24dp).asDrawable(this, getResources().getColor(R.color.grey_400))); + } + avatar.setOnClickListener(view -> + new AvatarSelector(this, LoaderManager.getInstance(this), new AvatarSelectedListener(), imageLoaded) + .show(this, avatar) + ); + } + + private void initializeStatusText() { + String status = DcHelper.get(this, DcHelper.CONFIG_SELF_STATUS); + statusView.setText(status); + } + + private void updateProfile() { + if (TextUtils.isEmpty(this.name.getText())) { + Toast.makeText(this, R.string.please_enter_name, Toast.LENGTH_LONG).show(); + return; + } + final String name = this.name.getText().toString(); + + new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + Context context = CreateProfileActivity.this; + DcHelper.set(context, DcHelper.CONFIG_DISPLAY_NAME, name); + setStatusText(); + + if (avatarChanged) { + try { + AvatarHelper.setSelfAvatar(CreateProfileActivity.this, avatarBmp); + Prefs.setProfileAvatarId(CreateProfileActivity.this, new SecureRandom().nextInt()); + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } + + return true; + } + + @Override + public void onPostExecute(Boolean result) { + super.onPostExecute(result); + + if (result) { + attachmentManager.cleanup(); + if (fromWelcome) { + Intent intent = new Intent(getApplicationContext(), ConversationListActivity.class); + intent.putExtra(ConversationListActivity.FROM_WELCOME, true); + startActivity(intent); + } + finish(); + } else { + Toast.makeText(CreateProfileActivity.this, R.string.error, Toast.LENGTH_LONG).show(); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void setStatusText() { + String newStatus = statusView.getText().toString().trim(); + DcHelper.set(this, DcHelper.CONFIG_SELF_STATUS, newStatus); + } + + private class AvatarSelectedListener implements AvatarSelector.AttachmentClickedListener { + @Override + public void onClick(int type) { + switch (type) { + case AvatarSelector.ADD_GALLERY: + AttachmentManager.selectImage(CreateProfileActivity.this, REQUEST_CODE_AVATAR); + break; + case AvatarSelector.REMOVE_PHOTO: + avatarBmp = null; + imageLoaded = false; + avatarChanged = true; + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_alt_white_24dp).asDrawable(CreateProfileActivity.this, getResources().getColor(R.color.grey_400))); + break; + case AvatarSelector.TAKE_PHOTO: + attachmentManager.capturePhoto(CreateProfileActivity.this, REQUEST_CODE_AVATAR); + break; + } + } + + @Override + public void onQuickAttachment(Uri inputFile) { + onFileSelected(inputFile); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/DummyActivity.java b/src/main/java/org/thoughtcrime/securesms/DummyActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..d2a108c92a89a08c37ada8d14895858d2179c1ea --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/DummyActivity.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.os.Bundle; + +// dummy activity that just pushes the app to foreground when fired. +// can also be used to work around android bug https://code.google.com/p/android/issues/detail?id=53313 +public class DummyActivity extends Activity { + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + finish(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/EphemeralMessagesDialog.java b/src/main/java/org/thoughtcrime/securesms/EphemeralMessagesDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..338660eef21ae3220eee8f28d7b7627e6defbd22 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/EphemeralMessagesDialog.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.widget.TextViewCompat; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.concurrent.TimeUnit; + +public class EphemeralMessagesDialog { + + private final static String TAG = EphemeralMessagesDialog.class.getSimpleName(); + + public static void show(final Context context, int currentSelectedTime, final @NonNull EphemeralMessagesInterface listener) { + CharSequence[] choices = context.getResources().getStringArray(R.array.ephemeral_message_durations); + int preselected = getPreselection(currentSelectedTime); + final int[] selectedChoice = new int[]{preselected}; + + View dialogView = View.inflate(context, R.layout.dialog_extended_options, null); + RadioGroup container = dialogView.findViewById(R.id.optionsContainer); + for (CharSequence choice : choices) { + + RadioButton radioButton = new RadioButton(context); + radioButton.setText(choice); + TextViewCompat.setTextAppearance(radioButton, android.R.style.TextAppearance_Medium); + + RadioGroup.LayoutParams params = new RadioGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, 0, 0, ViewUtil.dpToPx(context, 8)); + radioButton.setLayoutParams(params); + container.addView(radioButton); + } + + container.setOnCheckedChangeListener((group, checkedId) -> { + int childCount = group.getChildCount(); + for (int x = 0; x < childCount; x++) { + RadioButton btn = (RadioButton) group.getChildAt(x); + if (btn.getId() == checkedId) { + selectedChoice[0] = x; + } + } + }); + container.check(container.getChildAt(preselected).getId()); + + TextView messageView = dialogView.findViewById(R.id.description); + messageView.setText(context.getString(R.string.ephemeral_messages_hint)); + + AlertDialog.Builder builder = new AlertDialog.Builder(context) + .setTitle(R.string.ephemeral_messages) + .setView(dialogView) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, (dialog, which) -> { + final long burnAfter; + switch (selectedChoice[0]) { + case 1: burnAfter = TimeUnit.MINUTES.toSeconds(5); break; + case 2: burnAfter = TimeUnit.HOURS.toSeconds(1); break; + case 3: burnAfter = TimeUnit.DAYS.toSeconds(1); break; + case 4: burnAfter = TimeUnit.DAYS.toSeconds(7); break; + case 5: burnAfter = TimeUnit.DAYS.toSeconds(35); break; + case 6: burnAfter = TimeUnit.DAYS.toSeconds(365); break; + default: burnAfter = 0; break; + } + listener.onTimeSelected(burnAfter); + }) + .setNeutralButton(R.string.learn_more, (d, w) -> DcHelper.openHelp(context, "#ephemeralmsgs")); + builder.show(); + } + + public interface EphemeralMessagesInterface { + void onTimeSelected(long duration); + } + + private static int getPreselection(int timespan) { + if (timespan == 0) { + return 0; // off + } + // Choose timespan close to the current one out of available options. + if (timespan < TimeUnit.HOURS.toSeconds(1)) { + return 1; // 5 minutes + } + if (timespan < TimeUnit.DAYS.toSeconds(1)) { + return 2; // 1 hour + } + if (timespan < TimeUnit.DAYS.toSeconds(7)) { + return 3; // 1 day + } + if (timespan < TimeUnit.DAYS.toSeconds(35)) { + return 4; // 1 week + } + if (timespan < TimeUnit.DAYS.toSeconds(365)) { + return 5; // 5 weeks + } + return 6; // 1 year + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/FullMsgActivity.java b/src/main/java/org/thoughtcrime/securesms/FullMsgActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..017dc6e590e807e7e71b3f27bba2cca721d450ee --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/FullMsgActivity.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; + +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; +import java.lang.ref.WeakReference; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.types.HttpResponse; + +public class FullMsgActivity extends WebViewActivity +{ + public static final String MSG_ID_EXTRA = "msg_id"; + public static final String BLOCK_LOADING_REMOTE = "block_loading_remote"; + private String imageUrl; + private int msgId; + private DcContext dcContext; + private Rpc rpc; + private boolean loadRemoteContent = false; + private boolean blockLoadingRemote; + + enum LoadRemoteContent { + NEVER, + ONCE, + ALWAYS + } + + @Override + protected void onPreCreate() { + super.onPreCreate(); + toggleFakeProxy(true); + } + + @Override + protected void onCreate(Bundle state, boolean ready) { + super.onCreate(state, ready); + + registerForContextMenu(webView); + blockLoadingRemote = getIntent().getBooleanExtra(BLOCK_LOADING_REMOTE, false); + loadRemoteContent = !blockLoadingRemote && Prefs.getAlwaysLoadRemoteContent(this); + webView.getSettings().setBlockNetworkLoads(!loadRemoteContent); + + // setBuiltInZoomControls() adds pinch-to-zoom as well as two on-screen zoom control buttons. + // The latter are a bit annoying, however, they are deprecated anyway, + // and also Android docs recommend to disable them with setDisplayZoomControls(). + webView.getSettings().setBuiltInZoomControls(true); + webView.getSettings().setDisplayZoomControls(false); + + // disable useless and unwanted features: + // - JavaScript and Plugins are disabled by default, however, + // doing it explicitly here protects against changed base classes or bugs + // - Content- and File-access is enabled by default and disabled here + // - the other setAllow*() functions are related to enabled JavaScript only + // - "safe browsing" comes with privacy issues and already disabled in the base class + webView.getSettings().setJavaScriptEnabled(false); + webView.getSettings().setPluginState(WebSettings.PluginState.OFF); + webView.getSettings().setAllowContentAccess(false); + webView.getSettings().setAllowFileAccess(false); + + dcContext = DcHelper.getContext(this); + rpc = DcHelper.getRpc(this); + msgId = getIntent().getIntExtra(MSG_ID_EXTRA, 0); + String title = dcContext.getMsg(msgId).getSubject(); + if (title.isEmpty()) title = getString(R.string.chat_input_placeholder); + getSupportActionBar().setTitle(title); + + loadHtmlAsync(new WeakReference<>(this)); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + if (v instanceof WebView) { + WebView.HitTestResult result = ((WebView) v).getHitTestResult(); + if (result != null) { + int type = result.getType(); + if (type == WebView.HitTestResult.IMAGE_TYPE || type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + imageUrl = result.getExtra(); + if (!imageUrl.startsWith("data:")) { + super.onCreateContextMenu(menu, v, menuInfo); + this.getMenuInflater().inflate(R.menu.web_view_context, menu); + menu.setHeaderIcon(android.R.drawable.ic_menu_gallery); + menu.setHeaderTitle(imageUrl); + menu.findItem(R.id.action_export_image).setVisible(false); + } + } + } + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.action_export_image) { + // TODO: extract image from "data:" link or download URL + return true; + } else if (itemId == R.id.action_copy_link) { + Util.writeTextToClipboard(this, imageUrl); + Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + return true; + } + return super.onContextItemSelected(item); + } + + private static void loadHtmlAsync(final WeakReference activityReference) { + Util.runOnBackground(() -> { + try { + FullMsgActivity activity = activityReference.get(); + String html = activity.dcContext.getMsgHtml(activity.msgId); + + activity.runOnUiThread(() -> { + try { + // a base URL is needed, otherwise clicking links that reference document sections will not jump to sections + activityReference.get().webView.loadDataWithBaseURL("file://index.html", html, "text/html", null, null); + } catch (Exception e) { + e.printStackTrace(); + } + }); + + } catch (Exception e) { + e.printStackTrace(); + } + }); + } + + @Override + protected void onPause() { + super.onPause(); + if (isFinishing()) { + overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + this.getMenuInflater().inflate(R.menu.full_msg, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + if (item.getItemId() == R.id.load_remote_content) { + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(R.string.load_remote_content) + .setMessage(R.string.load_remote_content_ask); + + // we are using the buttons "[Always] [Never][Once]" in that order. + // 1. Checkmarks before [Always] and [Never] show the current state. + // 2. [Once] is also shown in always-mode and disables always-mode if selected + // (there was the idea to hide [Once] in always mode, but that looks more like a bug in the end) + // (maybe a usual Always-Checkbox and "[Cancel][OK]" buttons are an alternative, however, a [Once] + // would be required as well - probably as the leftmost button which is not that usable in + // not-always-mode where the dialog is used more often. Or [Ok] would mean "Once" as well as "Change checkbox setting", + // which is also a bit weird. Anyway, let's give the three buttons a try :) + final String checkmark = DynamicTheme.getCheckmarkEmoji(this) + " "; + String alwaysCheckmark = ""; + String onceCheckmark = ""; + String neverCheckmark = ""; + if (!blockLoadingRemote && Prefs.getAlwaysLoadRemoteContent(this)) { + alwaysCheckmark = checkmark; + } else if (loadRemoteContent) { + onceCheckmark = checkmark; + } else { + neverCheckmark = checkmark; + } + + if (!blockLoadingRemote) { + builder.setNeutralButton(alwaysCheckmark + getString(R.string.always), (dialog, which) -> onChangeLoadRemoteContent(LoadRemoteContent.ALWAYS)); + } + builder.setNegativeButton(neverCheckmark + getString(blockLoadingRemote ? R.string.no : R.string.never), (dialog, which) -> onChangeLoadRemoteContent(LoadRemoteContent.NEVER)); + builder.setPositiveButton(onceCheckmark + getString(R.string.once), (dialog, which) -> onChangeLoadRemoteContent(LoadRemoteContent.ONCE)); + + builder.show(); + return true; + } + return false; + } + + private void onChangeLoadRemoteContent(LoadRemoteContent loadRemoteContent) { + switch (loadRemoteContent) { + case NEVER: + this.loadRemoteContent = false; + if (!blockLoadingRemote) { + Prefs.setBooleanPreference(this, Prefs.ALWAYS_LOAD_REMOTE_CONTENT, false); + } + break; + case ONCE: + this.loadRemoteContent = true; + if (!blockLoadingRemote) { + Prefs.setBooleanPreference(this, Prefs.ALWAYS_LOAD_REMOTE_CONTENT, false); + } + break; + case ALWAYS: + this.loadRemoteContent = true; + Prefs.setBooleanPreference(this, Prefs.ALWAYS_LOAD_REMOTE_CONTENT, true); + break; + } + webView.getSettings().setBlockNetworkLoads(!this.loadRemoteContent); + webView.reload(); + } + + @Override + protected WebResourceResponse interceptRequest(String url) { + WebResourceResponse res = null; + try { + if (!loadRemoteContent) { + throw new Exception("loading remote content disabled"); + } + if (url == null) { + throw new Exception("no url specified"); + } + HttpResponse httpResponse = rpc.getHttpResponse(dcContext.getAccountId(), url); + String mimeType = httpResponse.mimetype; + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + byte[] blob = JsonUtils.decodeBase64(httpResponse.blob); + res = new WebResourceResponse(mimeType, httpResponse.encoding, new ByteArrayInputStream(blob)); + } catch (Exception e) { + e.printStackTrace(); + res = new WebResourceResponse("text/plain", "UTF-8", new ByteArrayInputStream(("Error: " + e.getMessage()).getBytes())); + } + return res; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java b/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..f5b19bc4cba1a5bf12877a5c7c7849509d0cd9ff --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -0,0 +1,437 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.thoughtcrime.securesms.components.AvatarSelector; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.util.SelectedContactsAdapter; +import org.thoughtcrime.securesms.util.SelectedContactsAdapter.ItemClickListener; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.Objects; + +import chat.delta.rpc.RpcException; + +public class GroupCreateActivity extends PassphraseRequiredActionBarActivity + implements ItemClickListener +{ + + public static final String EDIT_GROUP_CHAT_ID = "edit_group_chat_id"; + public static final String CREATE_BROADCAST = "create_broadcast"; + public static final String UNENCRYPTED = "unencrypted"; + public static final String CLONE_CHAT_EXTRA = "clone_chat"; + + private static final int PICK_CONTACT = 1; + private static final int REQUEST_CODE_AVATAR = 2759; + + private DcContext dcContext; + + private boolean unencrypted; + private boolean broadcast; + private EditText groupName; + private ListView lv; + private ImageView avatar; + private Bitmap avatarBmp; + private int groupChatId; + private boolean isEdit; + private boolean avatarChanged; + private boolean imageLoaded; + private AttachmentManager attachmentManager; + + @Override + protected void onCreate(Bundle state, boolean ready) { + dcContext = DcHelper.getContext(this); + setContentView(R.layout.group_create_activity); + broadcast = getIntent().getBooleanExtra(CREATE_BROADCAST, false); + unencrypted = getIntent().getBooleanExtra(UNENCRYPTED, false); + Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + + groupChatId = getIntent().getIntExtra(EDIT_GROUP_CHAT_ID, 0); + attachmentManager = new AttachmentManager(this, () -> {}); + avatarChanged = false; + + // groupChatId may be set during creation, + // so always check isEdit() + if(groupChatId !=0) { + isEdit = true; + DcChat dcChat = dcContext.getChat(groupChatId); + broadcast = dcChat.isOutBroadcast(); + unencrypted = !dcChat.isEncrypted(); + } + + int chatId = getIntent().getIntExtra(CLONE_CHAT_EXTRA, 0); + if (chatId != 0) { + DcChat dcChat = dcContext.getChat(chatId); + broadcast = dcChat.isOutBroadcast(); + unencrypted = !dcChat.isEncrypted(); + } + + initializeResources(); + } + + @Override + public void onResume() { + super.onResume(); + updateViewState(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + } + + @SuppressWarnings("ConstantConditions") + private void updateViewState() { + avatar.setEnabled(true); + groupName.setEnabled(true); + + String title; + if(isEdit()) { + title = getString(R.string.global_menu_edit_desktop); + } + else if(broadcast) { + title = getString(R.string.new_channel); + } + else if(unencrypted) { + title = getString(R.string.new_email); + } + else { + title = getString(R.string.menu_new_group); + } + getSupportActionBar().setTitle(title); + } + + private void initializeResources() { + lv = ViewUtil.findById(this, R.id.selected_contacts_list); + avatar = ViewUtil.findById(this, R.id.avatar); + groupName = ViewUtil.findById(this, R.id.group_name); + TextView chatHints = ViewUtil.findById(this, R.id.chat_hints); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(lv, false, false, false, true); + // apply padding to root to avoid collision with system bars + ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); + + initializeAvatarView(); + + SelectedContactsAdapter adapter = new SelectedContactsAdapter(this, GlideApp.with(this), broadcast, unencrypted); + adapter.setItemClickListener(this); + lv.setAdapter(adapter); + + int chatId = getIntent().getIntExtra(CLONE_CHAT_EXTRA, 0); + if (chatId != 0) { + DcChat dcChat = dcContext.getChat(chatId); + groupName.setText(dcChat.getName()); + File file = new File(dcChat.getProfileImage()); + if (file.exists()) { + setAvatarView(Uri.fromFile(file)); + } + + int[] contactIds = dcContext.getChatContacts(chatId); + ArrayList preselectedContactIds = new ArrayList<>(contactIds.length); + for (int id : contactIds) { + preselectedContactIds.add(id); + } + adapter.changeData(preselectedContactIds); + } else { + adapter.changeData(null); + } + + if (broadcast) { + groupName.setHint(R.string.channel_name); + chatHints.setVisibility(View.VISIBLE); + } else if (unencrypted) { + avatar.setVisibility(View.GONE); + groupName.setHint(R.string.subject); + chatHints.setVisibility(View.GONE); + } else { + chatHints.setVisibility(View.GONE); + } + + if(isEdit()) { + groupName.setText(dcContext.getChat(groupChatId).getName()); + lv.setVisibility(View.GONE); + } + } + + private void initializeAvatarView() { + imageLoaded = false; + if (groupChatId != 0) { + String avatarPath = dcContext.getChat(groupChatId).getProfileImage(); + File avatarFile = new File(avatarPath); + if (avatarFile.exists()) { + imageLoaded = true; + GlideApp.with(this) + .load(avatarFile) + .circleCrop() + .into(avatar); + } + } + if (!imageLoaded) { + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_white_24dp).asDrawable(this, ThemeUtil.getDummyContactColor(this))); + } + avatar.setOnClickListener(view -> + new AvatarSelector(this, LoaderManager.getInstance(this), new AvatarSelectedListener(), imageLoaded) + .show(this, avatar) + ); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.group_create, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.menu_create_group) { + String groupName = getGroupName(); + if (showGroupNameEmptyToast(groupName)) return true; + + if (groupChatId != 0) { + updateGroup(groupName); + } else { + createGroup(groupName); + } + + return true; + } + + return false; + } + + @Override + public void onItemClick(int contactId) { + if (contactId == DcContact.DC_CONTACT_ID_ADD_MEMBER) { + Intent intent = new Intent(this, ContactMultiSelectionActivity.class); + intent.putExtra(ContactSelectionListFragment.SELECT_UNENCRYPTED_EXTRA, unencrypted); + ArrayList preselectedContacts = new ArrayList<>(getAdapter().getContacts()); + intent.putExtra(ContactSelectionListFragment.PRESELECTED_CONTACTS, preselectedContacts); + startActivityForResult(intent, PICK_CONTACT); + } + } + + @Override + public void onItemDeleteClick(int contactId) { + getAdapter().remove(contactId); + } + + private void createGroup(String groupName) { + if (broadcast) { + try { + groupChatId = DcHelper.getRpc(this).createBroadcast(dcContext.getAccountId(), groupName); + } catch (RpcException e) { + e.printStackTrace(); + return; + } + } else if (unencrypted) { + try { + groupChatId = DcHelper.getRpc(this).createGroupChatUnencrypted(dcContext.getAccountId(), groupName); + } catch (RpcException e) { + e.printStackTrace(); + return; + } + } else { + groupChatId = dcContext.createGroupChat(groupName); + } + + for (int contactId : getAdapter().getContacts()) { + dcContext.addContactToChat(groupChatId, contactId); + } + if (avatarBmp!=null) { + AvatarHelper.setGroupAvatar(this, groupChatId, avatarBmp); + } + + attachmentManager.cleanup(); + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, groupChatId); + startActivity(intent); + finish(); + } + + private boolean showGroupNameEmptyToast(String groupName) { + if(groupName == null) { + Toast.makeText(this, getString(R.string.group_please_enter_group_name), Toast.LENGTH_LONG).show(); + return true; + } + return false; + } + + private void updateGroup(String groupName) { + if (groupChatId == 0) { + return; + } + dcContext.setChatName(groupChatId, groupName); + + if (avatarChanged) AvatarHelper.setGroupAvatar(this, groupChatId, avatarBmp); + + attachmentManager.cleanup(); + Intent intent = new Intent(); + intent.putExtra(GroupCreateActivity.EDIT_GROUP_CHAT_ID, groupChatId); + setResult(RESULT_OK, intent); + finish(); + } + + private SelectedContactsAdapter getAdapter() { + return (SelectedContactsAdapter)lv.getAdapter(); + } + + private @Nullable String getGroupName() { + String ret = groupName.getText() != null ? groupName.getText().toString() : null; + if(ret!=null) { + ret = ret.trim(); + if(ret.isEmpty()) { + ret = null; + } + } + return ret; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onActivityResult(int reqCode, int resultCode, final Intent data) { + super.onActivityResult(reqCode, resultCode, data); + + if (resultCode != Activity.RESULT_OK) + return; + + switch (reqCode) { + case REQUEST_CODE_AVATAR: + Uri inputFile = (data != null ? data.getData() : null); + onFileSelected(inputFile); + break; + + case PICK_CONTACT: + ArrayList contactIds = new ArrayList<>(); + for (Integer contactId : Objects.requireNonNull(data.getIntegerArrayListExtra(ContactMultiSelectionActivity.CONTACTS_EXTRA))) { + if(contactId != null) { + contactIds.add(contactId); + } + } + getAdapter().changeData(contactIds); + break; + + case ScribbleActivity.SCRIBBLE_REQUEST_CODE: + setAvatarView(data.getData()); + break; + } + } + + private void setAvatarView(Uri output) { + GlideApp.with(this) + .asBitmap() + .load(output) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_SIZE, AvatarHelper.AVATAR_SIZE) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + setAvatar(output, resource); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) {} + }); + } + + private void setAvatar(T model, Bitmap bitmap) { + avatarBmp = bitmap; + avatarChanged = true; + imageLoaded = true; + GlideApp.with(this) + .load(model) + .circleCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatar); + } + + private boolean isEdit() { + return isEdit; + } + + + private class AvatarSelectedListener implements AvatarSelector.AttachmentClickedListener { + @Override + public void onClick(int type) { + switch (type) { + case AvatarSelector.ADD_GALLERY: + AttachmentManager.selectImage(GroupCreateActivity.this, REQUEST_CODE_AVATAR); + break; + case AvatarSelector.REMOVE_PHOTO: + avatarBmp = null; + imageLoaded = false; + avatarChanged = true; + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_white_24dp).asDrawable(GroupCreateActivity.this, ThemeUtil.getDummyContactColor(GroupCreateActivity.this))); + break; + case AvatarSelector.TAKE_PHOTO: + attachmentManager.capturePhoto(GroupCreateActivity.this, REQUEST_CODE_AVATAR); + break; + } + } + + @Override + public void onQuickAttachment(Uri inputFile) { + onFileSelected(inputFile); + } + } + + private void onFileSelected(Uri inputFile) { + if (inputFile == null) { + inputFile = attachmentManager.getImageCaptureUri(); + } + + AvatarHelper.cropAvatar(this, inputFile); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/InstantOnboardingActivity.java b/src/main/java/org/thoughtcrime/securesms/InstantOnboardingActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..4df962c3792c08f11731d43a1233616bf22536ae --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/InstantOnboardingActivity.java @@ -0,0 +1,541 @@ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_URL; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.loader.app.LoaderManager; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcLot; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.components.AvatarSelector; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.proxy.ProxySettingsActivity; +import org.thoughtcrime.securesms.qr.RegistrationQrActivity; +import org.thoughtcrime.securesms.relay.EditRelayActivity; +import org.thoughtcrime.securesms.relay.RelayListActivity; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.io.File; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Objects; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class InstantOnboardingActivity extends BaseActionBarActivity implements DcEventCenter.DcEventDelegate { + + private static final String TAG = InstantOnboardingActivity.class.getSimpleName(); + private static final String DCACCOUNT = "dcaccount"; + private static final String DCLOGIN = "dclogin"; + private static final String INSTANCES_URL = "https://chatmail.at/relays"; + private static final String DEFAULT_CHATMAIL_HOST = "arcanechat.me"; + + public static final String FROM_WELCOME = "from_welcome"; + private static final int REQUEST_CODE_AVATAR = 1; + + private ImageView avatar; + private EditText name; + private TextView privacyPolicyBtn; + private Button signUpBtn; + + private boolean avatarChanged; + private boolean imageLoaded; + private String providerHost; + private String providerQrData; + private boolean isDcLogin; + + private AttachmentManager attachmentManager; + private Bitmap avatarBmp; + + private @Nullable ProgressDialog progressDialog; + private boolean cancelled; + + private DcContext dcContext; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + setContentView(R.layout.instant_onboarding_activity); + + Objects.requireNonNull(getSupportActionBar()).setTitle(R.string.onboarding_create_instant_account); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + boolean fromWelcome = getIntent().getBooleanExtra(FROM_WELCOME, false); + + if (DcHelper.getContext(this).isConfigured() == 1) { + // if account is configured it means we didn't come from Welcome screen nor from QR scanner, + // instead, user clicked a dcaccount:// URI directly, so we need to just offer to add a new relay + Uri uri = getIntent().getData(); + if (uri != null) { + Intent intent = new Intent(this, RelayListActivity.class); + intent.putExtra(RelayListActivity.EXTRA_QR_DATA, uri.toString()); + startActivity(intent); + finish(); + return; + } + // if URI is unexpectedly null, then fallback to new profile creation + AccountManager.getInstance().beginAccountCreation(this); + } + + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(!fromWelcome) { + @Override + public void handleOnBackPressed() { + AccountManager accountManager = AccountManager.getInstance(); + if (accountManager.canRollbackAccountCreation(InstantOnboardingActivity.this)) { + accountManager.rollbackAccountCreation(InstantOnboardingActivity.this); + } else { + finish(); + } + } + }); + + isDcLogin = false; + providerHost = DEFAULT_CHATMAIL_HOST; + providerQrData = DCACCOUNT + ":" + providerHost; + attachmentManager = new AttachmentManager(this, () -> {}); + avatarChanged = false; + registerForEvents(); + initializeResources(); + initializeProfile(); + handleIntent(); + updateProvider(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + getMenuInflater().inflate(R.menu.instant_onboarding_menu, menu); + MenuItem proxyItem = menu.findItem(R.id.menu_proxy_settings); + if (TextUtils.isEmpty(DcHelper.get(this, CONFIG_PROXY_URL))) { + proxyItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + } else { + boolean proxyEnabled = DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1; + proxyItem.setIcon(proxyEnabled? R.drawable.ic_proxy_enabled_24 : R.drawable.ic_proxy_disabled_24); + proxyItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + getOnBackPressedDispatcher().onBackPressed(); + return true; + } else if (itemId == R.id.menu_proxy_settings) { + startActivity(new Intent(this, ProxySettingsActivity.class)); + return true; + } else if (itemId == R.id.menu_view_log) { + startActivity(new Intent(this, LogViewActivity.class)); + return true; + } + + return false; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode != RESULT_OK) { + return; + } + + switch (requestCode) { + case REQUEST_CODE_AVATAR: + Uri inputFile = (data != null ? data.getData() : null); + onFileSelected(inputFile); + break; + + case ScribbleActivity.SCRIBBLE_REQUEST_CODE: + setAvatarView(data.getData()); + break; + + case IntentIntegrator.REQUEST_CODE: + String qrRaw = data.getStringExtra(RegistrationQrActivity.QRDATA_EXTRA); + if (qrRaw == null) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + if (scanResult != null && scanResult.getFormatName() != null) { + qrRaw = scanResult.getContents(); + } + } + if (qrRaw != null) { + setProviderFromQr(qrRaw); + } + break; + } + } + + private void setProviderFromQr(String rawQr) { + DcLot qrParsed = dcContext.checkQr(rawQr); + boolean isDcLogin = qrParsed.getState() == DcContext.DC_QR_LOGIN; + if (isDcLogin || qrParsed.getState() == DcContext.DC_QR_ACCOUNT) { + this.isDcLogin = isDcLogin; + providerHost = qrParsed.getText1(); + providerQrData = rawQr; + updateProvider(); + } else { + new AlertDialog.Builder(this) + .setMessage(R.string.qraccount_qr_code_cannot_be_used) + .setPositiveButton(R.string.ok, null) + .show(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + protected void onPause() { + super.onPause(); + + // Save display name and avatar in the unconfigured profile. + // If the currently selected profile is configured, then this means that rollbackAccountCreation() + // was called (see handleOnBackPressed() above), i.e. the newly created profile was removed already + // and we can't save the display name & avatar. + if (DcHelper.getContext(this).isConfigured() == 0) { + final String displayName = name.getText().toString(); + DcHelper.set(this, DcHelper.CONFIG_DISPLAY_NAME, TextUtils.isEmpty(displayName) ? null : displayName); + + if (avatarChanged) { + try { + AvatarHelper.setSelfAvatar(InstantOnboardingActivity.this, avatarBmp); + Prefs.setProfileAvatarId(InstantOnboardingActivity.this, new SecureRandom().nextInt()); + avatarChanged = false; + } catch (IOException e) { + Log.e(TAG, "Failed to save avatar", e); + } + } + } + } + + @Override + public void onResume() { + super.onResume(); + invalidateOptionsMenu(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + private void handleIntent() { + if (getIntent() != null && Intent.ACTION_VIEW.equals(getIntent().getAction())) { + Uri uri = getIntent().getData(); + if (uri == null) return; + + if (uri.getScheme().equalsIgnoreCase(DCACCOUNT) || uri.getScheme().equalsIgnoreCase(DCLOGIN)) { + setProviderFromQr(uri.toString()); + } + } + } + + private void setAvatarView(Uri output) { + GlideApp.with(this) + .asBitmap() + .load(output) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_SIZE, AvatarHelper.AVATAR_SIZE) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + avatarChanged = true; + imageLoaded = true; + avatarBmp = resource; + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) {} + }); + GlideApp.with(this) + .load(output) + .circleCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatar); + } + + private void onFileSelected(Uri inputFile) { + if (inputFile == null) { + inputFile = attachmentManager.getImageCaptureUri(); + } + + AvatarHelper.cropAvatar(this, inputFile); + } + + private void initializeResources() { + this.avatar = findViewById(R.id.avatar); + this.name = findViewById(R.id.name_text); + this.privacyPolicyBtn = findViewById(R.id.privacy_policy_button); + this.signUpBtn = findViewById(R.id.signup_button); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.container)); + + privacyPolicyBtn.setOnClickListener(view -> { + if (!isDcLogin) { + IntentUtils.showInBrowser(this, "https://" + providerHost + "/privacy.html"); + } + }); + + signUpBtn.setOnClickListener(view -> createProfile()); + + findViewById(R.id.use_other_server).setOnClickListener((v) -> { + IntentUtils.showInBrowser(this, INSTANCES_URL); + }); + findViewById(R.id.login_button).setOnClickListener((v) -> { + startActivity(new Intent(this, EditRelayActivity.class)); + }); + findViewById(R.id.scan_qr_button).setOnClickListener((v) -> { + new IntentIntegrator(this).setCaptureActivity(RegistrationQrActivity.class).initiateScan(); + }); + } + + private void updateProvider() { + if (isDcLogin) { + signUpBtn.setText(R.string.login_title); + privacyPolicyBtn.setTextColor(getResources().getColor(R.color.gray50)); + privacyPolicyBtn.setText(getString(R.string.qrlogin_ask_login, providerHost)); + } else { + signUpBtn.setText(R.string.instant_onboarding_create); + + try (TypedArray typedArray = obtainStyledAttributes(new int[]{R.attr.colorAccent})) { + privacyPolicyBtn.setTextColor(typedArray.getColor(0, Color.BLACK)); + } + + if (DEFAULT_CHATMAIL_HOST.equals(providerHost)) { + privacyPolicyBtn.setText(getString(R.string.instant_onboarding_agree_default2, providerHost)); + } else { + privacyPolicyBtn.setText(getString(R.string.instant_onboarding_agree_instance, providerHost)); + } + } + } + + private void initializeProfile() { + File avatarFile = AvatarHelper.getSelfAvatarFile(this); + if (avatarFile.exists() && avatarFile.length() > 0) { + imageLoaded = true; + GlideApp.with(this).load(avatarFile).circleCrop().into(avatar); + } else { + imageLoaded = false; + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_alt_white_24dp).asDrawable(this, getResources().getColor(R.color.grey_400))); + } + avatar.setOnClickListener(view -> + new AvatarSelector(this, LoaderManager.getInstance(this), new AvatarSelectedListener(), imageLoaded).show(this, avatar) + ); + + name.setText(DcHelper.get(this, DcHelper.CONFIG_DISPLAY_NAME)); + } + + private void registerForEvents() { + dcContext = DcHelper.getContext(this); + DcEventCenter eventCenter = DcHelper.getEventCenter(this); + eventCenter.addObserver(DcContext.DC_EVENT_CONFIGURE_PROGRESS, this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + + if (eventId == DcContext.DC_EVENT_CONFIGURE_PROGRESS) { + long progress = event.getData1Int(); + progressUpdate((int)progress); + } + } + + private void progressUpdate(int progress) { + int percent = progress / 10; + if (progressDialog != null) { + progressDialog.setMessage(getResources().getString(R.string.one_moment)+String.format(" %d%%", percent)); + } + } + + private void progressError(String data2) { + if (progressDialog != null) { + try { + progressDialog.dismiss(); + } catch (IllegalArgumentException e) { + // see https://stackoverflow.com/a/5102572/4557005 + Log.w(TAG, e); + } + } + WelcomeActivity.maybeShowConfigurationError(this, data2); + } + + private void progressSuccess() { + if (progressDialog != null) { + progressDialog.dismiss(); + } + + Intent intent = new Intent(getApplicationContext(), ConversationListActivity.class); + intent.putExtra(ConversationListActivity.FROM_WELCOME, true); + startActivity(intent); + finishAffinity(); + } + + @SuppressLint("StaticFieldLeak") + private void createProfile() { + if (TextUtils.isEmpty(this.name.getText())) { + Toast.makeText(this, R.string.please_enter_name, Toast.LENGTH_LONG).show(); + return; + } + final String name = this.name.getText().toString(); + + new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + Context context = InstantOnboardingActivity.this; + DcHelper.set(context, DcHelper.CONFIG_DISPLAY_NAME, name); + + if (avatarChanged) { + try { + AvatarHelper.setSelfAvatar(InstantOnboardingActivity.this, avatarBmp); + Prefs.setProfileAvatarId(InstantOnboardingActivity.this, new SecureRandom().nextInt()); + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } + + return true; + } + + @Override + public void onPostExecute(Boolean result) { + super.onPostExecute(result); + + if (result) { + attachmentManager.cleanup(); + startQrAccountCreation(providerQrData); + } else { + Toast.makeText(InstantOnboardingActivity.this, R.string.error, Toast.LENGTH_LONG).show(); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void startQrAccountCreation(String qrCode) + { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + cancelled = false; + + progressDialog = new ProgressDialog(this); + progressDialog.setMessage(getResources().getString(R.string.one_moment)); + progressDialog.setCanceledOnTouchOutside(false); + progressDialog.setCancelable(false); + progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getResources().getString(android.R.string.cancel), (dialog, which) -> { + cancelled = true; + dcContext.stopOngoingProcess(); + }); + progressDialog.show(); + + DcHelper.getEventCenter(this).captureNextError(); + + new Thread(() -> { + Rpc rpc = DcHelper.getRpc(this); + try { + rpc.addTransportFromQr(dcContext.getAccountId(), qrCode); + DcHelper.getEventCenter(this).endCaptureNextError(); + progressSuccess(); + } catch (RpcException e) { + DcHelper.getEventCenter(this).endCaptureNextError(); + if (!cancelled) { + Util.runOnMain(() -> progressError(e.getMessage())); + } + } + }).start(); + } + + private class AvatarSelectedListener implements AvatarSelector.AttachmentClickedListener { + @Override + public void onClick(int type) { + switch (type) { + case AvatarSelector.ADD_GALLERY: + AttachmentManager.selectImage(InstantOnboardingActivity.this, REQUEST_CODE_AVATAR); + break; + case AvatarSelector.REMOVE_PHOTO: + avatarBmp = null; + imageLoaded = false; + avatarChanged = true; + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_alt_white_24dp).asDrawable(InstantOnboardingActivity.this, getResources().getColor(R.color.grey_400))); + break; + case AvatarSelector.TAKE_PHOTO: + attachmentManager.capturePhoto(InstantOnboardingActivity.this, REQUEST_CODE_AVATAR); + break; + } + } + + @Override + public void onQuickAttachment(Uri inputFile) { + onFileSelected(inputFile); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/LocalHelpActivity.java b/src/main/java/org/thoughtcrime/securesms/LocalHelpActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..9455420d1dc9c414bd6c35a6fcc8b9353efa1da0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/LocalHelpActivity.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms; + +import android.content.res.AssetManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import org.thoughtcrime.securesms.util.Util; + +import java.io.InputStream; +import java.util.Locale; + +public class LocalHelpActivity extends WebViewActivity +{ + public static final String SECTION_EXTRA = "section_extra"; + + @Override + protected boolean allowInLockedMode() { return true; } + + @Override + protected void onCreate(Bundle state, boolean ready) { + super.onCreate(state, ready); + setForceDark(); + getSupportActionBar().setTitle(getString(R.string.menu_help)); + + String section = getIntent().getStringExtra(SECTION_EXTRA); + String helpPath = "help/LANG/help.html"; + String helpLang = "en"; + try { + Locale locale = Util.getLocale(); + String appLang = locale.getLanguage(); + String appCountry = locale.getCountry(); + if (assetExists(helpPath.replace("LANG", appLang))) { + helpLang = appLang; + } else if (assetExists(helpPath.replace("LANG", appLang+"_"+appCountry))) { + helpLang = appLang+"_"+appCountry; + } else { + appLang = appLang.substring(0, 2); + if (assetExists(helpPath.replace("LANG", appLang))) { + helpLang = appLang; + } + } + } catch(Exception e) { + e.printStackTrace(); + } + + webView.loadUrl("file:///android_asset/" + helpPath.replace("LANG", helpLang) + (section!=null? section : "")); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + this.getMenuInflater().inflate(R.menu.local_help, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == R.id.log_scroll_up) { + webView.scrollTo(0, 0); + return true; + } else if (itemId == R.id.learn_more) { + openOnlineUrl("https://arcanechat.me"); + return true; + } else if (itemId == R.id.privacy_policy) { + openOnlineUrl("https://arcanechat.me/privacy.html"); + return true; + } else if (itemId == R.id.contribute) { + openOnlineUrl("https://arcanechat.me/#contribute"); + return true; + } else if (itemId == R.id.report_issue) { + openOnlineUrl("https://github.com/ArcaneChat/android/issues"); + return true; + } + return false; + } + + @Override + public void onBackPressed() { + if (webView.canGoBack()) { + webView.goBack(); + } else { + super.onBackPressed(); + } + } + + private boolean assetExists(String fileName) { + // test using AssetManager.open(); + // AssetManager.list() is unreliable eg. on my Android 7 Moto G + // and also reported to be pretty slow. + boolean exists = false; + try { + AssetManager assetManager = getResources().getAssets(); + InputStream is = assetManager.open(fileName); + exists = true; + is.close(); + } catch(Exception e) { + ; // a non-existent asset is no error, the function's purpose is to check exactly that. + } + return exists; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/LogViewActivity.java b/src/main/java/org/thoughtcrime/securesms/LogViewActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..0f6ff0b6c6050db2aad86fb7f409f5b60798edc7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/LogViewActivity.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentTransaction; + +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.FileProviderUtil; + +import java.io.File; + +public class LogViewActivity extends BaseActionBarActivity { + + private static final String TAG = LogViewActivity.class.getSimpleName(); + + LogViewFragment logViewFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.log_view_activity); + logViewFragment = new LogViewFragment(); + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + transaction.replace(R.id.fragment_container, logViewFragment); + transaction.commit(); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.view_log, menu); + + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + Float newSize; + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.save_log) { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .alwaysGrantOnSdk30() + .ifNecessary() + .onAllGranted(() -> { + File outputDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + boolean success = logViewFragment.saveLogFile(outputDir) != null; + new AlertDialog.Builder(this) + .setMessage(success ? R.string.pref_saved_log : R.string.pref_save_log_failed) + .setPositiveButton(android.R.string.ok, null) + .show(); + }) + .execute(); + return true; + } else if (itemId == R.id.share_log) { + shareLog(); + return true; + } else if (itemId == R.id.log_zoom_in) { + newSize = logViewFragment.getLogTextSize() + 2.0f; + logViewFragment.setLogTextSize(newSize); + return false; + } else if (itemId == R.id.log_zoom_out) { + newSize = logViewFragment.getLogTextSize() - 2.0f; + logViewFragment.setLogTextSize(newSize); + return false; + } else if (itemId == R.id.log_scroll_down) { + logViewFragment.scrollDownLog(); + return false; + } else if (itemId == R.id.log_scroll_up) { + logViewFragment.scrollUpLog(); + return false; + } + + return false; + } + + public void shareLog() { + try { + File logFile = logViewFragment.saveLogFile(getExternalCacheDir()); + Uri uri = FileProviderUtil.getUriFor(this, logFile); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, getString(R.string.chat_share_with_title))); + } catch (Exception e) { + Log.e(TAG, "failed to share log", e); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/LogViewFragment.java b/src/main/java/org/thoughtcrime/securesms/LogViewFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..77e075d5395e5e3ebb433338de194782355976ff --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/LogViewFragment.java @@ -0,0 +1,284 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.app.ActivityManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Bundle; +import android.os.PowerManager; +import android.text.TextUtils; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.core.content.PermissionChecker; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.ref.WeakReference; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class LogViewFragment extends Fragment { + private EditText logPreview; + + public LogViewFragment() { + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_view_log, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + logPreview = (EditText) getView().findViewById(R.id.log_preview); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(getView().findViewById(R.id.content_container), true, false, true, true); + + new PopulateLogcatAsyncTask(this).execute(); + } + + public String getLogText() { + return logPreview==null? "null" : logPreview.getText().toString(); + } + + public Float getLogTextSize() { return logPreview.getTextSize(); } + + public void setLogTextSize(Float textSize) { + logPreview.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + } + + public void scrollDownLog() { + logPreview.requestFocus(); + logPreview.setSelection(logPreview.getText().length()); + } + + public void scrollUpLog() { + logPreview.requestFocus(); + logPreview.setSelection(0); + } + + public File saveLogFile(File outputDir) { + File logFile = null; + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd-HHmmss"); + Date now = new Date(); + String logFileName = "deltachat-log-" + dateFormat.format(now) + ".txt"; + + try { + String logText = logPreview.getText().toString(); + if(!logText.trim().equals("")){ + logFile = new File(outputDir + "/" + logFileName); + if(!logFile.exists()) logFile.createNewFile(); + + FileWriter logFileWriter = new FileWriter(logFile, false); + BufferedWriter logFileBufferWriter = new BufferedWriter(logFileWriter); + logFileBufferWriter.write(logText); + logFileBufferWriter.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + return logFile; + } + + private static String grabLogcat(LogViewFragment fragment) { + String command = "logcat -v threadtime -d -t 10000 *:I"; + try { + final Process process = Runtime.getRuntime().exec(command); + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream())); + final StringBuilder log = new StringBuilder(); + final String separator = System.getProperty("line.separator"); + + String line; + while ((line = bufferedReader.readLine()) != null) { + line = line.replaceFirst(" (\\d+) E ", " $1 \uD83D\uDD34 "); + line = line.replaceFirst(" (\\d+) W ", " $1 \uD83D\uDFE0 "); + line = line.replaceFirst(" (\\d+) I ", " $1 \uD83D\uDD35 "); + line = line.replaceFirst(" (\\d+) D ", " $1 \uD83D\uDFE2 "); + log.append(line); + log.append(separator); + } + return log.toString(); + } catch (Exception e) { + return "Error grabbing log: " + e; + } + } + + private class PopulateLogcatAsyncTask extends AsyncTask { + private final WeakReference weakFragment; + + public PopulateLogcatAsyncTask(LogViewFragment fragment) { + this.weakFragment = new WeakReference<>(fragment); + } + + @Override + protected String doInBackground(Void... voids) { + LogViewFragment fragment = weakFragment.get(); + if (fragment == null) return null; + + return "**This log may contain sensitive information. If you want to post it publicly you may examine and edit it beforehand.**\n\n" + + buildDescription(fragment) + "\n" + grabLogcat(fragment); + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + logPreview.setText(R.string.one_moment); + } + + @Override + protected void onPostExecute(String logcat) { + super.onPostExecute(logcat); + if (TextUtils.isEmpty(logcat)) { + // the log is in english, so it is fine if some of explaining strings are in english as well + logPreview.setText("Could not read the log on your device. You can still use ADB to get a debug log instead."); + return; + } + logPreview.setText(logcat); + } + } + + private static long asMegs(long bytes) { + return bytes / 1048576L; + } + + public static String getMemoryUsage(Context context) { + Runtime info = Runtime.getRuntime(); + return String.format(Locale.ENGLISH, "%dM (%.2f%% free, %dM max)", + asMegs(info.totalMemory()), + (float)info.freeMemory() / info.totalMemory() * 100f, + asMegs(info.maxMemory())); + } + + public static String getMemoryClass(Context context) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + String lowMem = ""; + + if (activityManager.isLowRamDevice()) { + lowMem = ", low-mem device"; + } + return activityManager.getMemoryClass() + lowMem; + } + + private static String buildDescription(LogViewFragment fragment) { + Context context = fragment.getActivity(); + + PowerManager powerManager = (PowerManager)context.getSystemService(Context.POWER_SERVICE); + final PackageManager pm = context.getPackageManager(); + final StringBuilder builder = new StringBuilder(); + + builder.append("device=") + .append(Build.MANUFACTURER).append(" ") + .append(Build.MODEL).append(" (") + .append(Build.PRODUCT).append(")\n"); + builder.append("android=").append(VERSION.RELEASE).append(" (") + .append(VERSION.INCREMENTAL).append(", ") + .append(Build.DISPLAY).append(")\n"); + builder.append("sdk=").append(Build.VERSION.SDK_INT).append("\n"); + builder.append("memory=").append(getMemoryUsage(context)).append("\n"); + builder.append("memoryClass=").append(getMemoryClass(context)).append("\n"); + builder.append("host=").append(Build.HOST).append("\n"); + builder.append("applicationId=").append(BuildConfig.APPLICATION_ID).append("\n"); + builder.append("app="); + try { + builder.append(pm.getApplicationLabel(pm.getApplicationInfo(context.getPackageName(), 0))) + .append(" ") + .append(pm.getPackageInfo(context.getPackageName(), 0).versionName) + .append("-") + .append(BuildConfig.FLAVOR) + .append(BuildConfig.DEBUG? "-debug" : "") + .append("\n"); + builder.append("versionCode=") + .append(pm.getPackageInfo(context.getPackageName(), 0).versionCode) + .append("\n"); + builder.append("installer=") + .append(pm.getInstallerPackageName(context.getPackageName())) + .append("\n"); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + builder.append("ignoreBatteryOptimizations=").append( + powerManager.isIgnoringBatteryOptimizations(context.getPackageName())).append("\n"); + } + builder.append("reliableService=").append( + Prefs.reliableService(context)).append("\n"); + + Locale locale = Util.getLocale(); + builder.append("lang=").append(locale.toString()).append("\n"); + boolean isRtl = Util.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL; + builder.append("rtl=").append(isRtl).append("\n"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + boolean notifPermGranted = PermissionChecker.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PermissionChecker.PERMISSION_GRANTED; + builder.append("post-notifications-granted=").append(notifPermGranted).append("\n"); + } else { + builder.append("post-notifications-granted=").append("\n"); + } + + final String token = FcmReceiveService.getToken(); + builder.append("push-enabled=").append(Prefs.isPushEnabled(context)).append("\n"); + builder.append("push-token=").append(token == null ? "" : token).append("\n"); + } catch (Exception e) { + builder.append("Unknown\n"); + } + + final Rpc rpc = DcHelper.getRpc(context); + final int accId = DcHelper.getContext(context).getAccountId(); + + builder.append("\n"); + try { + builder.append(rpc.getStorageUsageReportString(accId)); + } catch (RpcException e) { + builder.append(e); + } + builder.append(DcHelper.getContext(context).getInfo()); + + return builder.toString(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..c8d46085a34b2534ff3f6ff410f5661785b3621a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -0,0 +1,713 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMediaGalleryElement; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.components.MediaView; +import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.WeakHashMap; + +/** + * Activity for displaying media attachments in-app + */ +public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity + implements RecipientModifiedListener, LoaderManager.LoaderCallbacks { + + private final static String TAG = MediaPreviewActivity.class.getSimpleName(); + + public static final String ACTIVITY_TITLE_EXTRA = "activity_title"; + public static final String EDIT_AVATAR_CHAT_ID = "avatar_for_chat_id"; + public static final String ADDRESS_EXTRA = "address"; + public static final String OUTGOING_EXTRA = "outgoing"; + public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent"; + public static final String DC_MSG_ID = "dc_msg_id"; + public static final String OPENED_FROM_PROFILE = "opened_from_profile"; + + /** USE ONLY IF YOU HAVE NO MESSAGE ID! */ + public static final String DATE_EXTRA = "date"; + + /** USE ONLY IF YOU HAVE NO MESSAGE ID! */ + public static final String SIZE_EXTRA = "size"; + + @Nullable + private DcMsg messageRecord; + private DcContext dcContext; + private MediaItem initialMedia; + private ViewPager mediaPager; + private Recipient conversationRecipient; + private boolean leftIsRecent; + + private int restartItem = -1; + + private int editAvatarChatId = 0; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicTheme() { + public void onCreate(Activity activity) { + activity.setTheme(R.style.TextSecure_DarkTheme); // force dark theme + } + public void onResume(Activity activity) {} + }; + super.onPreCreate(); + } + + @SuppressWarnings("ConstantConditions") + @Override + protected void onCreate(Bundle bundle, boolean ready) { + setFullscreenIfPossible(); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setContentView(R.layout.media_preview_activity); + + editAvatarChatId = getIntent().getIntExtra(EDIT_AVATAR_CHAT_ID, 0); + @Nullable String title = getIntent().getStringExtra(ACTIVITY_TITLE_EXTRA); + if (title!=null) { + getSupportActionBar().setTitle(title); + } + + initializeViews(); + initializeResources(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void setFullscreenIfPossible() { + getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN); + } + + @Override + public void onModified(Recipient recipient) { + Util.runOnMain(this::initializeActionBar); + } + + @SuppressWarnings("ConstantConditions") + private void initializeActionBar() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + CharSequence relativeTimeSpan; + + if (mediaItem.date > 0) { + relativeTimeSpan = DateUtils.getExtendedRelativeTimeSpanString(this, mediaItem.date); + } else { + relativeTimeSpan = getString(R.string.draft); + } + + if (mediaItem.outgoing) getSupportActionBar().setTitle(getString(R.string.self)); + else { + int fromId = dcContext.getMsg(mediaItem.msgId).getFromId(); + getSupportActionBar().setTitle(dcContext.getContact(fromId).getDisplayName()); + } + + getSupportActionBar().setSubtitle(relativeTimeSpan); + } + } + + @Override + public void onResume() { + super.onResume(); + initializeMedia(); + } + + @Override + public void onPause() { + super.onPause(); + restartItem = cleanupMedia(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + initializeResources(); + } + + private void initializeViews() { + mediaPager = findViewById(R.id.media_pager); + mediaPager.setOffscreenPageLimit(1); + mediaPager.addOnPageChangeListener(new ViewPagerListener()); + } + + private void initializeResources() { + Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA); + + final Context context = getApplicationContext(); + this.dcContext = DcHelper.getContext(context); + final int msgId = getIntent().getIntExtra(DC_MSG_ID, DcMsg.DC_MSG_NO_ID); + + if(msgId == DcMsg.DC_MSG_NO_ID) { + messageRecord = null; + long date = getIntent().getLongExtra(DATE_EXTRA, 0); + long size = getIntent().getLongExtra(SIZE_EXTRA, 0); + initialMedia = new MediaItem(null, getIntent().getData(), null, getIntent().getType(), + DcMsg.DC_MSG_NO_ID, date, size, false); + + if (address != null) { + conversationRecipient = Recipient.from(context, address); + } else { + conversationRecipient = null; + } + } else { + messageRecord = dcContext.getMsg(msgId); + initialMedia = new MediaItem(Recipient.fromChat(context, msgId), Uri.fromFile(messageRecord.getFileAsFile()), + messageRecord.getFilename(), messageRecord.getFilemime(), messageRecord.getId(), messageRecord.getDateReceived(), + messageRecord.getFilebytes(), messageRecord.isOutgoing()); + conversationRecipient = Recipient.fromChat(context, msgId); + } + leftIsRecent = getIntent().getBooleanExtra(LEFT_IS_RECENT_EXTRA, false); + restartItem = -1; + + } + + private void initializeMedia() { + + // if you search for the place where the media are loaded, go to 'onCreateLoader'. + + Log.w(TAG, "Loading Part URI: " + initialMedia); + if (messageRecord != null) { + getSupportLoaderManager().restartLoader(0, null, this); + } else { + mediaPager.setAdapter(new SingleItemPagerAdapter(this, GlideApp.with(this), + getWindow(), initialMedia.uri, initialMedia.name, initialMedia.type, initialMedia.size)); + } + } + + private int cleanupMedia() { + int restartItem = mediaPager.getCurrentItem(); + + mediaPager.removeAllViews(); + mediaPager.setAdapter(null); + + return restartItem; + } + + private void editAvatar() { + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.EDIT_GROUP_CHAT_ID, editAvatarChatId); + startActivity(intent); + finish(); // avoid the need to update the enlarged-avatar + } + + + private void showOverview() { + if (getIntent().getBooleanExtra(OPENED_FROM_PROFILE, false)) { + finish(); + } + else if(conversationRecipient.getAddress().isDcChat()) { + Intent intent = new Intent(this, AllMediaActivity.class); + intent.putExtra(AllMediaActivity.CHAT_ID_EXTRA, conversationRecipient.getAddress().getDcChatId()); + intent.putExtra(AllMediaActivity.FORCE_GALLERY, true); + startActivity(intent); + finish(); + } + else if(conversationRecipient.getAddress().isDcContact()) { + Intent intent = new Intent(this, AllMediaActivity.class); + intent.putExtra(AllMediaActivity.CONTACT_ID_EXTRA, conversationRecipient.getAddress().getDcContactId()); + intent.putExtra(AllMediaActivity.FORCE_GALLERY, true); + startActivity(intent); + finish(); + } + } + + private void share() { + MediaItem mediaItem = getCurrentMediaItem(); + if (mediaItem != null) { + DcHelper.openForViewOrShare(this, mediaItem.msgId, Intent.ACTION_SEND); + } + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + private void saveToDisk() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore(this)) { + performSavetoDisk(mediaItem); + return; + } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .alwaysGrantOnSdk30() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> { + performSavetoDisk(mediaItem); + }) + .execute(); + }); + } + } + + private void performSavetoDisk(@NonNull MediaItem mediaItem) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); + long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, mediaItem.name)); + } + + private void showInChat() { + MediaItem mediaItem = getCurrentMediaItem(); + if (mediaItem == null || mediaItem.msgId == DcMsg.DC_MSG_NO_ID) { + Log.w(TAG, "mediaItem missing."); + return; + } + + DcMsg dcMsg = dcContext.getMsg(mediaItem.msgId); + if (dcMsg.getId() == DcMsg.DC_MSG_NO_ID) { + Log.w(TAG, "cannot get message object."); + return; + } + + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, dcMsg.getChatId()); + intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, DcMsg.getMessagePosition(dcMsg, dcContext)); + startActivity(intent); + } + + @SuppressLint("StaticFieldLeak") + private void deleteMedia() { + MediaItem mediaItem = getCurrentMediaItem(); + if (mediaItem == null || mediaItem.msgId == DcMsg.DC_MSG_NO_ID) { + return; + } + + DcMsg dcMsg = dcContext.getMsg(mediaItem.msgId); + DcChat dcChat = dcContext.getChat(dcMsg.getChatId()); + + String text = getResources().getQuantityString( + dcChat.isDeviceTalk() ? R.plurals.ask_delete_messages_simple : R.plurals.ask_delete_messages, + 1, 1); + int positiveBtnLabel = dcChat.isSelfTalk() ? R.string.delete : R.string.delete_for_me; + final int[] messageIds = new int[]{mediaItem.msgId}; + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(text); + builder.setCancelable(true); + builder.setNeutralButton(android.R.string.cancel, null); + builder.setPositiveButton(positiveBtnLabel, (dialogInterface, which) -> { + Util.runOnAnyBackgroundThread(() -> dcContext.deleteMsgs(messageIds)); + finish(); + }); + + if(dcChat.isEncrypted() && dcChat.canSend() && !dcChat.isSelfTalk() && dcMsg.isOutgoing()) { + builder.setNegativeButton(R.string.delete_for_everyone, (d, which) -> { + Util.runOnAnyBackgroundThread(() -> dcContext.sendDeleteRequest(messageIds)); + finish(); + }); + AlertDialog dialog = builder.show(); + Util.redButton(dialog, AlertDialog.BUTTON_NEGATIVE); + Util.redPositiveButton(dialog); + } else { + AlertDialog dialog = builder.show(); + Util.redPositiveButton(dialog); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + + menu.clear(); + MenuInflater inflater = this.getMenuInflater(); + inflater.inflate(R.menu.media_preview, menu); + Util.redMenuItem(menu, R.id.delete); + + if (!isMediaInDb()) { + menu.findItem(R.id.media_preview__overview).setVisible(false); + menu.findItem(R.id.media_preview__share).setVisible(false); + menu.findItem(R.id.delete).setVisible(false); + menu.findItem(R.id.show_in_chat).setVisible(false); + } + + if (editAvatarChatId==0) { + menu.findItem(R.id.media_preview__edit).setVisible(false); + } + + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == R.id.media_preview__edit) { + editAvatar(); + return true; + } else if (itemId == R.id.media_preview__overview) { + showOverview(); + return true; + } else if (itemId == R.id.media_preview__share) { + share(); + return true; + } else if (itemId == R.id.save) { + saveToDisk(); + return true; + } else if (itemId == R.id.delete) { + deleteMedia(); + return true; + } else if (itemId == R.id.show_in_chat) { + showInChat(); + return true; + } else if (itemId == android.R.id.home) { + finish(); + return true; + } + + return false; + } + + private boolean isMediaInDb() { + return conversationRecipient != null; + } + + private @Nullable MediaItem getCurrentMediaItem() { + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + return adapter.getMediaItemFor(mediaPager.getCurrentItem()); + } else { + return null; + } + } + + public static boolean isTypeSupported(final Slide slide) { + return slide != null && (slide.hasVideo() || slide.hasImage()); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new PagingMediaLoader(this, messageRecord, false); + } + + @Override + public void onLoadFinished(Loader loader, @Nullable DcMediaGalleryElement data) { + if (data != null) { + @SuppressWarnings("ConstantConditions") + DcMediaPagerAdapter adapter = new DcMediaPagerAdapter(this, GlideApp.with(this), + getWindow(), data, leftIsRecent); + mediaPager.setAdapter(adapter); + adapter.setActive(true); + + if (restartItem < 0) mediaPager.setCurrentItem(data.getPosition()); + else mediaPager.setCurrentItem(restartItem); + } + } + + @Override + public void onLoaderReset(Loader loader) { + + } + + private class ViewPagerListener extends ExtendedOnPageChangedListener { + + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + MediaItem item = adapter.getMediaItemFor(position); + if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this); + + initializeActionBar(); + } + } + + + @Override + public void onPageUnselected(int position) { + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + try { + MediaItem item = adapter.getMediaItemFor(position); + if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Ignoring invalid position index"); + } + adapter.pause(position); + } + } + } + + private static class SingleItemPagerAdapter extends PagerAdapter implements MediaItemAdapter { + + private final GlideRequests glideRequests; + private final Window window; + private final Uri uri; + private final String name; + private final String mediaType; + private final long size; + + private final LayoutInflater inflater; + + SingleItemPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, + @NonNull Window window, @NonNull Uri uri, @Nullable String name, @NonNull String mediaType, + long size) + { + this.glideRequests = glideRequests; + this.window = window; + this.uri = uri; + this.name = name; + this.mediaType = mediaType; + this.size = size; + this.inflater = LayoutInflater.from(context); + } + + @Override + public int getCount() { + return 1; + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return view == object; + } + + @Override + public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { + View itemView = inflater.inflate(R.layout.media_view_page, container, false); + MediaView mediaView = itemView.findViewById(R.id.media_view); + + try { + mediaView.set(glideRequests, window, uri, name, mediaType, size, true); + } catch (IOException e) { + Log.w(TAG, e); + } + + container.addView(itemView); + + return itemView; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); + mediaView.cleanup(); + + container.removeView((FrameLayout)object); + } + + @Override + public MediaItem getMediaItemFor(int position) { + return new MediaItem(null, uri, name, mediaType, DcMsg.DC_MSG_NO_ID, -1, -1, true); + } + + @Override + public void pause(int position) { + + } + } + + private static class DcMediaPagerAdapter extends PagerAdapter implements MediaItemAdapter { + + private final WeakHashMap mediaViews = new WeakHashMap<>(); + + private final Context context; + private final GlideRequests glideRequests; + private final Window window; + private final DcMediaGalleryElement gallery; + private final boolean leftIsRecent; + + private boolean active; + private int autoPlayPosition; + + DcMediaPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, + @NonNull Window window, @NonNull DcMediaGalleryElement gallery, + boolean leftIsRecent) + { + this.context = context.getApplicationContext(); + this.glideRequests = glideRequests; + this.window = window; + this.gallery = gallery; + this.leftIsRecent = leftIsRecent; + this.autoPlayPosition = gallery.getPosition(); + } + + public void setActive(boolean active) { + this.active = active; + notifyDataSetChanged(); + } + + @Override + public int getCount() { + if (!active) return 0; + else return gallery.getCount(); + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return view == object; + } + + @Override + public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { + View itemView = LayoutInflater.from(context).inflate(R.layout.media_view_page, container, false); + MediaView mediaView = itemView.findViewById(R.id.media_view); + boolean autoplay = position == autoPlayPosition; + int cursorPosition = getCursorPosition(position); + + autoPlayPosition = -1; + + gallery.moveToPosition(cursorPosition); + + DcMsg msg = gallery.getMessage(); + + try { + //noinspection ConstantConditions + mediaView.set(glideRequests, window, Uri.fromFile(msg.getFileAsFile()), msg.getFilename(), + msg.getFilemime(), msg.getFilebytes(), autoplay); + } catch (IOException e) { + Log.w(TAG, e); + } + + mediaViews.put(position, mediaView); + container.addView(itemView); + + return itemView; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); + mediaView.cleanup(); + + mediaViews.remove(position); + container.removeView((FrameLayout)object); + } + + public MediaItem getMediaItemFor(int position) { + gallery.moveToPosition(getCursorPosition(position)); + DcMsg msg = gallery.getMessage(); + + if (msg.getFile() == null) throw new AssertionError(); + + return new MediaItem(Recipient.fromChat(context, msg.getId()), + Uri.fromFile(msg.getFileAsFile()), + msg.getFilename(), + msg.getFilemime(), + msg.getId(), + msg.getDateReceived(), + msg.getFilebytes(), + msg.isOutgoing()); + } + + @Override + public void pause(int position) { + MediaView mediaView = mediaViews.get(position); + if (mediaView != null) mediaView.pause(); + } + + private int getCursorPosition(int position) { + if (leftIsRecent) return position; + else return gallery.getCount() - 1 - position; + } + } + + private static class MediaItem { + private final @Nullable Recipient recipient; + private final @NonNull Uri uri; + private final @Nullable String name; + private final @NonNull String type; + private final int msgId; + private final long date; + private final long size; + private final boolean outgoing; + + private MediaItem(@Nullable Recipient recipient, + @NonNull Uri uri, + @Nullable String name, + @NonNull String type, + int msgId, + long date, + long size, + boolean outgoing) + { + this.recipient = recipient; + this.uri = uri; + this.name = name; + this.type = type; + this.msgId = msgId; + this.date = date; + this.size = size; + this.outgoing = outgoing; + } + } + + interface MediaItemAdapter { + MediaItem getMediaItemFor(int position); + void pause(int position); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java b/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..02c773ee5292cd132cf54af7c1fae48e0c062ae8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java @@ -0,0 +1,167 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.view.Menu; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.view.ActionMode; +import androidx.fragment.app.Fragment; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Set; + +public abstract class MessageSelectorFragment + extends Fragment + implements DcEventCenter.DcEventDelegate +{ + protected ActionMode actionMode; + protected DcContext dcContext; + + protected abstract void setCorrectMenuVisibility(Menu menu); + + protected ActionMode getActionMode() { + return actionMode; + } + + protected DcMsg getSelectedMessageRecord(Set messageRecords) { + if (messageRecords.size() == 1) return messageRecords.iterator().next(); + else throw new AssertionError(); + } + + protected void handleDisplayDetails(DcMsg dcMsg) { + View view = View.inflate(getActivity(), R.layout.message_details_view, null); + TextView detailsText = view.findViewById(R.id.details_text); + detailsText.setText(dcContext.getMsgInfo(dcMsg.getId())); + + AlertDialog d = new AlertDialog.Builder(getActivity()) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .create(); + d.show(); + } + + protected void handleDeleteMessages(int chatId, final Set messageRecords) { + handleDeleteMessages(chatId, DcMsg.msgSetToIds(messageRecords)); + } + + protected void handleDeleteMessages(int chatId, final int[] messageIds) { + DcChat dcChat = dcContext.getChat(chatId); + boolean canDeleteForAll = true; + if (dcChat.isEncrypted() && dcChat.canSend() && !dcChat.isSelfTalk()) { + for(int msgId : messageIds) { + DcMsg msg = dcContext.getMsg(msgId); + if (!msg.isOutgoing() || msg.isInfo()) { + canDeleteForAll = false; + break; + } + } + } else { + canDeleteForAll = false; + } + + String text = getActivity().getResources().getQuantityString( + dcChat.isDeviceTalk() ? R.plurals.ask_delete_messages_simple : R.plurals.ask_delete_messages, + messageIds.length, messageIds.length); + int positiveBtnLabel = dcChat.isSelfTalk() ? R.string.delete : R.string.delete_for_me; + + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()) + .setMessage(text) + .setCancelable(true) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(positiveBtnLabel, (d, which) -> { + Util.runOnAnyBackgroundThread(() -> dcContext.deleteMsgs(messageIds)); + if (actionMode != null) actionMode.finish(); + }); + + if(canDeleteForAll) { + builder.setNegativeButton(R.string.delete_for_everyone, (d, which) -> { + Util.runOnAnyBackgroundThread(() -> dcContext.sendDeleteRequest(messageIds)); + if (actionMode != null) actionMode.finish(); + }); + AlertDialog dialog = builder.show(); + Util.redButton(dialog, AlertDialog.BUTTON_NEGATIVE); + Util.redPositiveButton(dialog); + } else { + AlertDialog dialog = builder.show(); + Util.redPositiveButton(dialog); + } + } + + protected void handleSaveAttachment(final Set messageRecords) { + SaveAttachmentTask.showWarningDialog(getContext(), (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore(getContext())) { + performSave(messageRecords); + return; + } + + Permissions.with(getActivity()) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .alwaysGrantOnSdk30() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> performSave(messageRecords)) + .execute(); + }); + } + + private void performSave(Set messageRecords) { + SaveAttachmentTask.Attachment[] attachments = new SaveAttachmentTask.Attachment[messageRecords.size()]; + int index = 0; + for (DcMsg message : messageRecords) { + attachments[index] = new SaveAttachmentTask.Attachment( + Uri.fromFile(message.getFileAsFile()), message.getFilemime(), message.getDateReceived(), message.getFilename()); + index++; + } + SaveAttachmentTask saveTask = new SaveAttachmentTask(getContext()); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments); + } + + protected void handleShowInChat(final DcMsg dcMsg) { + Intent intent = new Intent(getContext(), ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, dcMsg.getChatId()); + intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, DcMsg.getMessagePosition(dcMsg, dcContext)); + startActivity(intent); + } + + protected void handleShare(final DcMsg dcMsg) { + DcHelper.openForViewOrShare(getContext(), dcMsg.getId(), Intent.ACTION_SEND); + } + + protected void handleResendMessage(final Set dcMsgsSet) { + int[] ids = DcMsg.msgSetToIds(dcMsgsSet); + Util.runOnAnyBackgroundThread(() -> { + boolean success = dcContext.resendMsgs(ids); + Util.runOnMain(() -> { + Activity activity = getActivity(); + if (activity == null || activity.isFinishing()) return; + if (success) { + actionMode.finish(); + Toast.makeText(getContext(), R.string.sending, Toast.LENGTH_SHORT).show(); + } else { + new AlertDialog.Builder(activity) + .setMessage(dcContext.getLastError()) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + }); + }); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/MuteDialog.java b/src/main/java/org/thoughtcrime/securesms/MuteDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..175ee2066ce4a7c9663a3af5527019a3ec21d8ea --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/MuteDialog.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import java.util.concurrent.TimeUnit; + +public class MuteDialog { + + public static void show(final Context context, final @NonNull MuteSelectionListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.menu_mute); + builder.setNegativeButton(R.string.cancel, null); + builder.setItems(R.array.mute_durations, (dialog, which) -> { + final long muteUntil; + + // See https://c.delta.chat/classdc__context__t.html#a6460395925d49d2053bc95224bf5ce37. + switch (which) { + case 0: muteUntil = TimeUnit.HOURS.toSeconds(1); break; + case 1: muteUntil = TimeUnit.HOURS.toSeconds(8); break; + case 2: muteUntil = TimeUnit.DAYS.toSeconds(1); break; + case 3: muteUntil = TimeUnit.DAYS.toSeconds(7); break; + case 4: muteUntil = -1; break; // mute forever + default: muteUntil = 0; break; + } + + listener.onMuted(muteUntil); + }); + + builder.show(); + } + + public interface MuteSelectionListener { + void onMuted(long duration); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..b3e1b0d92ea62ed61ae9febcf1c2241eee06a9dc --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.ConversationActivity.CHAT_ID_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.TEXT_EXTRA; +import static org.thoughtcrime.securesms.util.ShareUtil.acquireRelayMessageContent; +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.qr.QrActivity; +import org.thoughtcrime.securesms.qr.QrCodeHandler; +import org.thoughtcrime.securesms.util.MailtoUtil; + +/** + * Activity container for starting a new conversation. + * + * @author Moxie Marlinspike + * + */ +public class NewConversationActivity extends ContactSelectionActivity { + + private static final String TAG = NewConversationActivity.class.getSimpleName(); + + @Override + public void onCreate(Bundle bundle, boolean ready) { + super.onCreate(bundle, ready); + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + handleIntent(); + } + + private void handleIntent() { + Intent intent = getIntent(); + String action = intent.getAction(); + if(Intent.ACTION_VIEW.equals(action) || Intent.ACTION_SENDTO.equals(action)) { + try { + Uri uri = intent.getData(); + if(uri != null) { + String scheme = uri.getScheme(); + if(MailtoUtil.isMailto(uri)) { + String textToShare = MailtoUtil.getText(uri); + String[] recipientsArray = MailtoUtil.getRecipients(uri); + if (recipientsArray.length >= 1) { + if (!textToShare.isEmpty()) { + getIntent().putExtra(TEXT_EXTRA, textToShare); + } + final String addr = recipientsArray[0]; + final DcContext dcContext = DcHelper.getContext(this); + int contactId = dcContext.lookupContactIdByAddr(addr); + if (contactId == 0 && dcContext.mayBeValidAddr(addr)) { + contactId = dcContext.createContact(null, recipientsArray[0]); + } + if (contactId == 0) { + Toast.makeText(this, R.string.bad_email_address, Toast.LENGTH_LONG).show(); + } else { + onContactSelected(contactId); + } + } else { + Intent shareIntent = new Intent(this, ShareActivity.class); + shareIntent.putExtra(Intent.EXTRA_TEXT, textToShare); + startActivity(shareIntent); + finish(); + } + } else if(scheme != null && scheme.startsWith("http")) { + Intent shareIntent = new Intent(this, ShareActivity.class); + shareIntent.putExtra(Intent.EXTRA_TEXT, uri.toString()); + startActivity(shareIntent); + finish(); + } + } + } + catch(Exception e) { + Log.e(TAG, "start activity from external 'mailto:' link failed", e); + } + } + } + + @Override + public void onContactSelected(int contactId) { + if(contactId == DcContact.DC_CONTACT_ID_NEW_GROUP) { + startActivity(new Intent(this, GroupCreateActivity.class)); + } else if(contactId == DcContact.DC_CONTACT_ID_NEW_UNENCRYPTED_GROUP) { + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.UNENCRYPTED, true); + startActivity(intent); + } else if(contactId == DcContact.DC_CONTACT_ID_NEW_BROADCAST) { + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.CREATE_BROADCAST, true); + startActivity(intent); + } else if (contactId == DcContact.DC_CONTACT_ID_QR_INVITE) { + new IntentIntegrator(this).setCaptureActivity(QrActivity.class).initiateScan(); + } + else { + final DcContext dcContext = DcHelper.getContext(this); + if (dcContext.getChatIdByContactId(contactId)!=0) { + openConversation(dcContext.getChatIdByContactId(contactId)); + } else { + String name = dcContext.getContact(contactId).getDisplayName(); + new AlertDialog.Builder(this) + .setMessage(getString(R.string.ask_start_chat_with, name)) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + openConversation(dcContext.createChatByContactId(contactId)); + }).show(); + } + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case IntentIntegrator.REQUEST_CODE: + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.onScanPerformed(scanResult); + break; + default: + break; + } + } + + private void openConversation(int chatId) { + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(TEXT_EXTRA, getIntent().getStringExtra(TEXT_EXTRA)); + intent.setDataAndType(getIntent().getData(), getIntent().getType()); + + intent.putExtra(CHAT_ID_EXTRA, chatId); + if (isRelayingMessageContent(this)) { + acquireRelayMessageContent(this, intent); + } + startActivity(intent); + finish(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java b/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..c043a046b3f245311941930de82b1d3808a9624b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.service.GenericForegroundService; + +public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarActivity { + private static final String TAG = PassphraseRequiredActionBarActivity.class.getSimpleName(); + + @Override + protected final void onCreate(Bundle savedInstanceState) { + Log.w(TAG, "onCreate(" + savedInstanceState + ")"); + + if (allowInLockedMode()) { + super.onCreate(savedInstanceState); + onCreate(savedInstanceState, true); + return; + } + + if (GenericForegroundService.isForegroundTaskStarted()) { + // this does not prevent intent set by onNewIntent(), + // however, at least during onboarding, + // this catches a lot of situations with otherwise weird app states. + super.onCreate(savedInstanceState); + finish(); + return; + } + + if (!DcHelper.isConfigured(getApplicationContext())) { + Intent intent = new Intent(this, WelcomeActivity.class); + startActivity(intent); + super.onCreate(savedInstanceState); + finish(); + } else { + super.onCreate(savedInstanceState); + } + + if (!isFinishing()) { + onCreate(savedInstanceState, true); + } + } + + protected void onCreate(Bundle savedInstanceState, boolean ready) {} + + // "Locked Mode" is when the account is not configured (Welcome screen) + // or when sharing a backup (Add second device). + // The app is "locked" to an activity if you will. + // In "Locked Mode" the user should not leave that activity otherwise the state would be lost - + // so eg. tapping app icon or notifications MUST NOT replace activity stack. + // However, sometimes it is fine to allow to pushing activities in these situations, + // like to see the logs or offline help. + protected boolean allowInLockedMode() { return false; } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileActivity.java b/src/main/java/org/thoughtcrime/securesms/ProfileActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..48e390ca375cf072fd5ad491fa6f0fd55bd4b242 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileActivity.java @@ -0,0 +1,461 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Intent; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.File; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class ProfileActivity extends PassphraseRequiredActionBarActivity + implements DcEventCenter.DcEventDelegate +{ + + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String CONTACT_ID_EXTRA = "contact_id"; + + private static final int REQUEST_CODE_PICK_RINGTONE = 1; + + private DcContext dcContext; + private Rpc rpc; + private int chatId; + private boolean chatIsMultiUser; + private boolean chatIsDeviceTalk; + private boolean chatIsMailingList; + private boolean chatIsOutBroadcast; + private boolean chatIsInBroadcast; + private int contactId; + private boolean contactIsBot; + private Toolbar toolbar; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + dcContext = DcHelper.getContext(this); + rpc = DcHelper.getRpc(this); + } + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.profile_activity); + + initializeResources(); + + setSupportActionBar(this.toolbar); + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar != null) { + String title = getString(R.string.profile); + if (chatIsMailingList) { + title = getString(R.string.mailing_list); + } else if (chatIsOutBroadcast || chatIsInBroadcast) { + title = getString(R.string.channel); + } else if (chatIsMultiUser) { + title = getString(R.string.tab_group); + } else if (contactIsBot) { + title = getString(R.string.bot); + } else if (!chatIsDeviceTalk && !isSelfProfile()) { + title = getString(R.string.tab_contact); + } + + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setTitle(title); + } + + Bundle args = new Bundle(); + args.putInt(ProfileFragment.CHAT_ID_EXTRA, (chatId == 0) ? -1 : chatId); + args.putInt(ProfileFragment.CONTACT_ID_EXTRA, (contactId == 0) ? -1 : contactId); + initFragment(R.id.fragment_container, new ProfileFragment(), args); + + DcEventCenter eventCenter = DcHelper.getEventCenter(this); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (!isSelfProfile()) { + getMenuInflater().inflate(R.menu.profile_common, menu); + boolean canReceive = true; + + if (chatId != 0) { + DcChat dcChat = dcContext.getChat(chatId); + menu.findItem(R.id.menu_clone).setVisible(chatIsMultiUser && !chatIsInBroadcast && !chatIsOutBroadcast && !chatIsMailingList); + if (chatIsDeviceTalk) { + menu.findItem(R.id.edit_name).setVisible(false); + menu.findItem(R.id.show_encr_info).setVisible(false); + menu.findItem(R.id.share).setVisible(false); + } else if (chatIsMultiUser) { + // menu.findItem(R.id.edit_name).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + if (chatIsOutBroadcast) { + canReceive = false; + } else { + if (!dcChat.isEncrypted() + || !dcChat.canSend() + || chatIsMailingList) { + menu.findItem(R.id.edit_name).setVisible(false); + } + } + menu.findItem(R.id.share).setVisible(false); + } + } else { + menu.findItem(R.id.menu_clone).setVisible(false); + canReceive = false; + } + + if (!canReceive) { + menu.findItem(R.id.menu_mute_notifications).setVisible(false); + menu.findItem(R.id.menu_sound).setVisible(false); + menu.findItem(R.id.menu_vibrate).setVisible(false); + } + + if (isContactProfile()) { + menu.findItem(R.id.edit_name).setTitle(R.string.menu_edit_name); + } + + if (!isContactProfile() || chatIsDeviceTalk) { + menu.findItem(R.id.block_contact).setVisible(false); + } + } + + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem item = menu.findItem(R.id.block_contact); + if(item!=null) { + item.setTitle(dcContext.getContact(contactId).isBlocked()? R.string.menu_unblock_contact : R.string.menu_block_contact); + Util.redMenuItem(menu, R.id.block_contact); + } + + item = menu.findItem(R.id.menu_mute_notifications); + if(item!=null) { + item.setTitle(dcContext.getChat(chatId).isMuted()? R.string.menu_unmute : R.string.menu_mute); + } + + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + getMenuInflater().inflate(R.menu.profile_title_context, menu); + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(this).removeObservers(this); + super.onDestroy(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + } + + private void initializeResources() { + chatId = getIntent().getIntExtra(CHAT_ID_EXTRA, 0); + contactId = getIntent().getIntExtra(CONTACT_ID_EXTRA, 0); + contactIsBot = false; + chatIsMultiUser = false; + chatIsDeviceTalk = false; + chatIsMailingList= false; + chatIsInBroadcast = false; + chatIsOutBroadcast = false; + + if (contactId!=0) { + DcContact dcContact = dcContext.getContact(contactId); + chatId = dcContext.getChatIdByContactId(contactId); + contactIsBot = dcContact.isBot(); + } + + if(chatId!=0) { + DcChat dcChat = dcContext.getChat(chatId); + chatIsMultiUser = dcChat.isMultiUser(); + chatIsDeviceTalk = dcChat.isDeviceTalk(); + chatIsMailingList = dcChat.isMailingList(); + chatIsInBroadcast = dcChat.isInBroadcast(); + chatIsOutBroadcast = dcChat.isOutBroadcast(); + if(!chatIsMultiUser) { + final int[] members = dcContext.getChatContacts(chatId); + contactId = members.length>=1? members[0] : 0; + } + } + + this.toolbar = ViewUtil.findById(this, R.id.toolbar); + } + + private boolean isContactProfile() { + // contact-profiles are profiles without a chat or with a one-to-one chat + return contactId!=0 && (chatId==0 || !chatIsMultiUser); + } + + private boolean isSelfProfile() { + return isContactProfile() && contactId==DcContact.DC_CONTACT_ID_SELF; + } + + // handle events + // ========================================================================= + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.menu_mute_notifications) { + onNotifyOnOff(); + } else if (itemId == R.id.menu_sound) { + onSoundSettings(); + } else if (itemId == R.id.menu_vibrate) { + onVibrateSettings(); + } else if (itemId == R.id.edit_name) { + onEditName(); + } else if (itemId == R.id.share) { + onShare(); + } else if (itemId == R.id.show_encr_info) { + onEncrInfo(); + } else if (itemId == R.id.block_contact) { + onBlockContact(); + } else if (itemId == R.id.menu_clone) { + onClone(); + } + + return false; + } + + @Override + public boolean onContextItemSelected(@NonNull MenuItem item) { + super.onContextItemSelected(item); + if (item.getItemId() == R.id.copy_addr_to_clipboard) { + onCopyAddrToClipboard(); + } + return false; + } + + private void onNotifyOnOff() { + if (dcContext.getChat(chatId).isMuted()) { + setMuted(0); + } + else { + MuteDialog.show(this, this::setMuted); + } + } + + private void setMuted(final long duration) { + if (chatId != 0) { + dcContext.setChatMuteDuration(chatId, duration); + } + } + + private void onSoundSettings() { + Uri current = Prefs.getChatRingtone(this, dcContext.getAccountId(), chatId); + Uri defaultUri = Prefs.getNotificationRingtone(this); + + if (current == null) current = Settings.System.DEFAULT_NOTIFICATION_URI; + else if (current.toString().isEmpty()) current = null; + + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultUri); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + + startActivityForResult(intent, REQUEST_CODE_PICK_RINGTONE); + } + + private void onVibrateSettings() { + int checkedItem = Prefs.getChatVibrate(this, dcContext.getAccountId(), chatId).getId(); + int[] selectedChoice = new int[]{checkedItem}; + new AlertDialog.Builder(this) + .setTitle(R.string.pref_vibrate) + .setSingleChoiceItems(R.array.recipient_vibrate_entries, checkedItem, + (dialog, which) -> selectedChoice[0] = which) + .setPositiveButton(R.string.ok, + (dialog, which) -> Prefs.setChatVibrate(this, dcContext.getAccountId(), chatId, Prefs.VibrateState.fromId(selectedChoice[0]))) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + public void onEnlargeAvatar() { + String profileImagePath; + String title; + Uri profileImageUri; + boolean enlargeAvatar = true; + if(chatId!=0) { + DcChat dcChat = dcContext.getChat(chatId); + profileImagePath = dcChat.getProfileImage(); + title = dcChat.getName(); + enlargeAvatar = dcChat.isEncrypted() && !dcChat.isSelfTalk() && !dcChat.isDeviceTalk(); + } else { + DcContact dcContact = dcContext.getContact(contactId); + profileImagePath = dcContact.getProfileImage(); + title = dcContact.getDisplayName(); + } + + File file = new File(profileImagePath); + + if (enlargeAvatar && file.exists()) { + profileImageUri = Uri.fromFile(file); + String type = "image/" + profileImagePath.substring(profileImagePath.lastIndexOf(".") + 1); + + Intent intent = new Intent(this, MediaPreviewActivity.class); + intent.setDataAndType(profileImageUri, type); + intent.putExtra(MediaPreviewActivity.ACTIVITY_TITLE_EXTRA, title); + intent.putExtra( // show edit-button, if the user is allowed to edit the name/avatar + MediaPreviewActivity.EDIT_AVATAR_CHAT_ID, + (chatIsMultiUser && !chatIsInBroadcast && !chatIsMailingList) ? chatId : 0 + ); + startActivity(intent); + } else if (chatIsMultiUser){ + onEditName(); + } + } + + private void onEditName() { + if (chatIsMultiUser) { + DcChat dcChat = dcContext.getChat(chatId); + if (chatIsMailingList || dcChat.canSend()) { + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.EDIT_GROUP_CHAT_ID, chatId); + startActivity(intent); + } + } + else { + int accountId = dcContext.getAccountId(); + DcContact dcContact = dcContext.getContact(contactId); + + String authName = dcContact.getAuthName(); + if (TextUtils.isEmpty(authName)) { + authName = dcContact.getAddr(); + } + + View gl = View.inflate(this, R.layout.single_line_input, null); + EditText inputField = gl.findViewById(R.id.input_field); + inputField.setText(dcContact.getName()); + inputField.setSelection(inputField.getText().length()); + inputField.setHint(getString(R.string.edit_name_placeholder, authName)); + + new AlertDialog.Builder(this) + .setTitle(R.string.menu_edit_name) + .setMessage(getString(R.string.edit_name_explain, authName)) + .setView(gl) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + String newName = inputField.getText().toString(); + try { + rpc.changeContactName(accountId, contactId, newName); + } catch (RpcException e) { + e.printStackTrace(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .show(); + } + } + + private void onShare() { + Intent composeIntent = new Intent(); + DcContact dcContact = dcContext.getContact(contactId); + if (dcContact.isKeyContact()) { + ShareUtil.setSharedContactId(composeIntent, contactId); + } else { + ShareUtil.setSharedText(composeIntent, dcContact.getAddr()); + } + ConversationListRelayingActivity.start(this, composeIntent); + } + + private void onCopyAddrToClipboard() { + DcContact dcContact = dcContext.getContact(contactId); + Util.writeTextToClipboard(this, dcContact.getAddr()); + Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + } + + private void onEncrInfo() { + String infoStr = isContactProfile() ? + dcContext.getContactEncrInfo(contactId) : dcContext.getChatEncrInfo(chatId); + new AlertDialog.Builder(this) + .setMessage(infoStr) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + private void onBlockContact() { + DcContact dcContact = dcContext.getContact(contactId); + if(dcContact.isBlocked()) { + new AlertDialog.Builder(this) + .setMessage(R.string.ask_unblock_contact) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.menu_unblock_contact, (dialog, which) -> { + dcContext.blockContact(contactId, 0); + }).show(); + } + else { + AlertDialog dialog = new AlertDialog.Builder(this) + .setMessage(R.string.ask_block_contact) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.menu_block_contact, (d, which) -> { + dcContext.blockContact(contactId, 1); + }).show(); + Util.redPositiveButton(dialog); + } + } + + private void onClone() { + Intent intent = new Intent(this, GroupCreateActivity.class); + intent.putExtra(GroupCreateActivity.CLONE_CHAT_EXTRA, chatId); + startActivity(intent); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode==REQUEST_CODE_PICK_RINGTONE && resultCode== Activity.RESULT_OK && data!=null) { + Uri value = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + Uri defaultValue = Prefs.getNotificationRingtone(this); + + if (defaultValue.equals(value)) value = null; + else if (value == null) value = Uri.EMPTY; + + Prefs.setChatRingtone(this, dcContext.getAccountId(), chatId, value); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileAdapter.java b/src/main/java/org/thoughtcrime/securesms/ProfileAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..a08e0c4a9e6252c3e724696b2fa74ba6adc7e287 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileAdapter.java @@ -0,0 +1,383 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public class ProfileAdapter extends RecyclerView.Adapter +{ + public static final int ITEM_AVATAR = 10; + public static final int ITEM_DIVIDER = 20; + public static final int ITEM_SIGNATURE = 25; + public static final int ITEM_ALL_MEDIA_BUTTON = 30; + public static final int ITEM_SEND_MESSAGE_BUTTON = 35; + public static final int ITEM_LAST_SEEN = 40; + public static final int ITEM_INTRODUCED_BY = 45; + public static final int ITEM_ADDRESS = 50; + public static final int ITEM_HEADER = 53; + public static final int ITEM_MEMBERS = 55; + public static final int ITEM_SHARED_CHATS = 60; + + private final @NonNull Context context; + private final @NonNull Fragment fragment; + private final @NonNull DcContext dcContext; + private @Nullable DcChat dcChat; + private @Nullable DcContact dcContact; + + private final @NonNull ArrayList itemData = new ArrayList<>(); + private DcChatlist itemDataSharedChats; + private String itemDataStatusText; + private boolean isOutBroadcast; + private int[] memberList; + private final Set selectedMembers; + + private final LayoutInflater layoutInflater; + private final ItemClickListener clickListener; + private final GlideRequests glideRequests; + + static class ItemData { + final int viewType; + final int contactId; + final int chatlistIndex; + final String label; + final int icon; + + ItemData(int viewType, String label, int icon) { + this(viewType, 0, 0, label, icon); + } + + ItemData(int viewType, int contactId, int chatlistIndex) { + this(viewType, contactId, chatlistIndex, null, 0); + } + + private ItemData(int viewType, int contactId, int chatlistIndex, @Nullable String label, int icon) { + this.viewType = viewType; + this.contactId = contactId; + this.chatlistIndex = chatlistIndex; + this.label = label; + this.icon = icon; + } + }; + + public ProfileAdapter(@NonNull Fragment fragment, + @NonNull GlideRequests glideRequests, + @Nullable ItemClickListener clickListener) + { + super(); + this.fragment = fragment; + this.context = fragment.requireContext(); + this.glideRequests = glideRequests; + this.clickListener = clickListener; + this.dcContext = DcHelper.getContext(context); + this.layoutInflater = LayoutInflater.from(context); + this.selectedMembers= new HashSet<>(); + } + + @Override + public int getItemCount() { + return itemData.size(); + } + + @Override + public int getItemViewType(int i) { + return itemData.get(i).viewType; + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + } + + @NonNull + @Override + public ProfileAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == ITEM_HEADER) { + final View item = LayoutInflater.from(context).inflate(R.layout.contact_selection_list_divider, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_DIVIDER) { + final View item = LayoutInflater.from(context).inflate(R.layout.profile_divider, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_MEMBERS) { + final ContactSelectionListItem item = (ContactSelectionListItem)layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_SHARED_CHATS) { + final ConversationListItem item = (ConversationListItem)layoutInflater.inflate(R.layout.conversation_list_item_view, parent, false); + item.hideItemDivider(); + return new ViewHolder(item); + } else if (viewType == ITEM_SIGNATURE) { + final ProfileStatusItem item = (ProfileStatusItem)layoutInflater.inflate(R.layout.profile_status_item, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_AVATAR) { + final ProfileAvatarItem item = (ProfileAvatarItem)layoutInflater.inflate(R.layout.profile_avatar_item, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_ALL_MEDIA_BUTTON || viewType == ITEM_SEND_MESSAGE_BUTTON) { + final ProfileTextItem item = (ProfileTextItem)layoutInflater.inflate(R.layout.profile_text_item_button, parent, false); + return new ViewHolder(item); + } else if (viewType == ITEM_LAST_SEEN || viewType == ITEM_INTRODUCED_BY || viewType == ITEM_ADDRESS) { + final ProfileTextItem item = (ProfileTextItem)layoutInflater.inflate(R.layout.profile_text_item_small, parent, false); + return new ViewHolder(item); + } else { + final ProfileTextItem item = (ProfileTextItem)layoutInflater.inflate(R.layout.profile_text_item, parent, false); + return new ViewHolder(item); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int i) { + ViewHolder holder = (ViewHolder) viewHolder; + ItemData data = itemData.get(i); + if (holder.itemView instanceof ContactSelectionListItem) { + ContactSelectionListItem contactItem = (ContactSelectionListItem) holder.itemView; + + int contactId = data.contactId; + DcContact dcContact = null; + String label = null; + String name; + String addr = null; + + if (contactId == DcContact.DC_CONTACT_ID_ADD_MEMBER) { + if (isOutBroadcast) { + name = context.getString(R.string.add_recipients); + } else { + name = context.getString(R.string.group_add_members); + } + } + else if (contactId == DcContact.DC_CONTACT_ID_QR_INVITE) { + name = context.getString(R.string.qrshow_title); + } + else { + dcContact = dcContext.getContact(contactId); + name = dcContact.getDisplayName(); + addr = dcContact.getAddr(); + } + + contactItem.unbind(glideRequests); + contactItem.set(glideRequests, contactId, dcContact, name, addr, label, false, true); + contactItem.setSelected(selectedMembers.contains(contactId)); + contactItem.setOnClickListener(view -> clickListener.onMemberClicked(contactId)); + contactItem.setOnLongClickListener(view -> {clickListener.onMemberLongClicked(contactId); return true;}); + } + else if (holder.itemView instanceof ConversationListItem) { + ConversationListItem conversationListItem = (ConversationListItem) holder.itemView; + int chatlistIndex = data.chatlistIndex; + + int chatId = itemDataSharedChats.getChatId(chatlistIndex); + DcChat chat = dcContext.getChat(chatId); + DcLot summary = itemDataSharedChats.getSummary(chatlistIndex, chat); + + conversationListItem.bind(DcHelper.getThreadRecord(context, summary, chat), + itemDataSharedChats.getMsgId(chatlistIndex), summary, glideRequests, + Collections.emptySet(), false); + conversationListItem.setOnClickListener(view -> clickListener.onSharedChatClicked(chatId)); + } + else if(holder.itemView instanceof ProfileStatusItem) { + ProfileStatusItem item = (ProfileStatusItem) holder.itemView; + item.setOnLongClickListener(view -> {clickListener.onStatusLongClicked(); return true;}); + item.set(data.label); + } + else if(holder.itemView instanceof ProfileAvatarItem) { + ProfileAvatarItem item = (ProfileAvatarItem) holder.itemView; + item.setAvatarClickListener(view -> clickListener.onAvatarClicked()); + item.set(glideRequests, dcChat, dcContact, memberList); + } + else if(holder.itemView instanceof ProfileTextItem) { + ProfileTextItem item = (ProfileTextItem) holder.itemView; + item.setOnClickListener(view -> clickListener.onSettingsClicked(data.viewType)); + boolean tintIcon = data.viewType != ITEM_INTRODUCED_BY; + item.set(data.label, data.icon, tintIcon); + if (data.viewType == ITEM_LAST_SEEN || data.viewType == ITEM_ADDRESS) { + int padding = (int)((float)context.getResources().getDimensionPixelSize(R.dimen.contact_list_normal_padding) * 1.2); + item.setPadding(item.getPaddingLeft(), item.getPaddingTop(), item.getPaddingRight(), padding); + if (data.viewType == ITEM_ADDRESS) { + fragment.registerForContextMenu(item); + } + } else if (data.viewType == ITEM_INTRODUCED_BY) { + int padding = context.getResources().getDimensionPixelSize(R.dimen.contact_list_normal_padding); + item.setPadding(item.getPaddingLeft(), padding, item.getPaddingRight(), item.getPaddingBottom()); + } else if (data.viewType == ITEM_ALL_MEDIA_BUTTON && dcChat != null) { + Util.runOnAnyBackgroundThread(() -> { + String c = getAllMediaCountString(dcChat.getId()); + Util.runOnMain(() -> { + item.setValue(c); + }); + }); + } + } else if (data.viewType == ITEM_HEADER) { + TextView textView = holder.itemView.findViewById(R.id.label); + textView.setText(data.label); + } + } + + public interface ItemClickListener { + void onSettingsClicked(int settingsId); + void onStatusLongClicked(); + void onSharedChatClicked(int chatId); + void onMemberClicked(int contactId); + void onMemberLongClicked(int contactId); + void onAvatarClicked(); + } + + public void toggleMemberSelection(int contactId) { + if (!selectedMembers.remove(contactId)) { + selectedMembers.add(contactId); + } + notifyDataSetChanged(); + } + + @NonNull + public Collection getSelectedMembers() { + return new HashSet<>(selectedMembers); + } + + public int getSelectedMembersCount() { + return selectedMembers.size(); + } + + @NonNull + public String getStatusText() { + return itemDataStatusText; + } + + public void clearSelection() { + selectedMembers.clear(); + notifyDataSetChanged(); + } + + public void changeData(@Nullable int[] memberList, @Nullable DcContact dcContact, @Nullable DcChatlist sharedChats, @Nullable DcChat dcChat) { + this.dcChat = dcChat; + this.dcContact = dcContact; + itemData.clear(); + itemDataSharedChats = sharedChats; + itemDataStatusText = ""; + isOutBroadcast = dcChat != null && dcChat.isOutBroadcast(); + boolean isMailingList = dcChat != null && dcChat.isMailingList(); + boolean isInBroadcast = dcChat != null && dcChat.isInBroadcast(); + boolean isSelfTalk = dcChat != null && dcChat.isSelfTalk(); + boolean isDeviceTalk = dcChat != null && dcChat.isDeviceTalk(); + this.memberList = memberList; + + itemData.add(new ItemData(ITEM_AVATAR, null, 0)); + + if (isSelfTalk || dcContact != null && !dcContact.getStatus().isEmpty()) { + itemDataStatusText = isSelfTalk ? context.getString(R.string.saved_messages_explain) : dcContact.getStatus(); + itemData.add(new ItemData(ITEM_SIGNATURE, itemDataStatusText, 0)); + } else { + itemData.add(new ItemData(ITEM_DIVIDER, null, 0)); + } + + itemData.add(new ItemData(ITEM_ALL_MEDIA_BUTTON, context.getString(R.string.apps_and_media), R.drawable.ic_apps_24)); + + if (dcContact != null && !isDeviceTalk && !isSelfTalk) { + itemData.add(new ItemData(ITEM_SEND_MESSAGE_BUTTON, context.getString(R.string.send_message), R.drawable.ic_send_sms_white_24dp)); + } + + /* + if (dcContact != null && !isDeviceTalk && !isSelfTalk) { + long lastSeenTimestamp = dcContact.getLastSeen(); + String lastSeenTxt; + if (lastSeenTimestamp == 0) { + lastSeenTxt = context.getString(R.string.last_seen_unknown); + } + else { + lastSeenTxt = context.getString(R.string.last_seen_at, DateUtils.getExtendedTimeSpanString(context, lastSeenTimestamp)); + } + itemData.add(new ItemData(ITEM_LAST_SEEN, lastSeenTxt, 0)); + } + */ + + if (memberList!=null && !isInBroadcast && !isMailingList) { + itemData.add(new ItemData(ITEM_DIVIDER, null, 0)); + if (dcChat != null) { + if (dcChat.canSend() && dcChat.isEncrypted()) { + if (!isOutBroadcast) { + itemData.add(new ItemData(ITEM_MEMBERS, DcContact.DC_CONTACT_ID_ADD_MEMBER, 0)); + } + itemData.add(new ItemData(ITEM_MEMBERS, DcContact.DC_CONTACT_ID_QR_INVITE, 0)); + } + } + for (int value : memberList) { + itemData.add(new ItemData(ITEM_MEMBERS, value, 0)); + } + } + + if (!isDeviceTalk && sharedChats != null && sharedChats.getCnt() > 0) { + itemData.add(new ItemData(ITEM_HEADER, context.getString(R.string.profile_shared_chats), 0)); + for (int i = 0; i < sharedChats.getCnt(); i++) { + itemData.add(new ItemData(ITEM_SHARED_CHATS, 0, i)); + } + } + + if (dcContact != null && !isDeviceTalk && !isSelfTalk) { + itemData.add(new ItemData(ITEM_DIVIDER, null, 0)); + int verifierId = dcContact.getVerifierId(); + if (verifierId != 0) { + String introducedBy; + if (verifierId == DcContact.DC_CONTACT_ID_SELF) { + introducedBy = context.getString(R.string.verified_by_you); + } else { + introducedBy = context.getString(R.string.verified_by, dcContext.getContact(verifierId).getDisplayName()); + } + itemData.add(new ItemData(ITEM_INTRODUCED_BY, introducedBy, dcContact.isVerified()? R.drawable.ic_verified : 0)); + } else if (dcContact.isVerified()) { + String introducedBy = context.getString(R.string.verified_by_unknown); + itemData.add(new ItemData(ITEM_INTRODUCED_BY, introducedBy, R.drawable.ic_verified)); + } + + if (dcContact != null) { + itemData.add(new ItemData(ITEM_ADDRESS, dcContact.getAddr(), 0)); + } + } + + notifyDataSetChanged(); + } + + public int ALL_MEDIA_COUNT_MAX = 500; + public int getAllMediaCount(int chatId) { + int c = dcContext.getChatMedia(chatId, DcMsg.DC_MSG_IMAGE, DcMsg.DC_MSG_GIF, DcMsg.DC_MSG_VIDEO).length; + if (c < ALL_MEDIA_COUNT_MAX) { + c += dcContext.getChatMedia(chatId, DcMsg.DC_MSG_AUDIO, DcMsg.DC_MSG_VOICE, 0).length; + } + if (c < ALL_MEDIA_COUNT_MAX) { + c += dcContext.getChatMedia(chatId, DcMsg.DC_MSG_FILE, DcMsg.DC_MSG_WEBXDC, 0).length; + } + return c; + } + + public String getAllMediaCountString(int chatId) { + final int c = getAllMediaCount(chatId); + if (c == 0) { + return context.getString(R.string.none); + } else if (c >= ALL_MEDIA_COUNT_MAX) { + return ALL_MEDIA_COUNT_MAX + "+"; + } else { + return c + ""; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileAvatarItem.java b/src/main/java/org/thoughtcrime/securesms/ProfileAvatarItem.java new file mode 100644 index 0000000000000000000000000000000000000000..3e319ba3ba0cfd689e24e003537017196f068d04 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileAvatarItem.java @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; + +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class ProfileAvatarItem extends LinearLayout implements RecipientModifiedListener { + + private AvatarView avatarView; + private TextView nameView; + private TextView subtitleView; + + private Recipient recipient; + private GlideRequests glideRequests; + + public ProfileAvatarItem(Context context) { + super(context); + } + + public ProfileAvatarItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + avatarView = findViewById(R.id.avatar); + nameView = findViewById(R.id.name); + subtitleView = findViewById(R.id.subtitle); + + ViewUtil.setTextViewGravityStart(nameView, getContext()); + } + + public void set(@NonNull GlideRequests glideRequests, @Nullable DcChat dcChat, @Nullable DcContact dcContact, @Nullable int[] members) { + this.glideRequests = glideRequests; + int memberCount = members != null ? members.length : 0; + + String name = ""; + String subtitle = null; + if (dcChat != null) { + recipient = new Recipient(getContext(), dcChat); + name = dcChat.getName(); + + if (dcChat.isMailingList()) { + subtitle = dcChat.getMailinglistAddr(); + } else if (dcChat.isOutBroadcast()) { + subtitle = getContext().getResources().getQuantityString(R.plurals.n_recipients, memberCount, memberCount); + } else if (dcChat.getType() == DcChat.DC_CHAT_TYPE_GROUP) { + if (memberCount > 1 || Util.contains(members, DcContact.DC_CONTACT_ID_SELF)) { + subtitle = getContext().getResources().getQuantityString(R.plurals.n_members, memberCount, memberCount); + } + } else if (dcContact != null && !dcChat.isSelfTalk() && !dcChat.isDeviceTalk()) { + long timestamp = dcContact.getLastSeen(); + if (timestamp == 0) { + subtitle = getContext().getString(R.string.last_seen_unknown); + } else { + subtitle = getContext().getString(R.string.last_seen_at, DateUtils.getExtendedTimeSpanString(getContext(), timestamp)); + } + } + } else if (dcContact != null) { + recipient = new Recipient(getContext(), dcContact); + name = dcContact.getDisplayName(); + + long timestamp = dcContact.getLastSeen(); + if (timestamp == 0) { + subtitle = getContext().getString(R.string.last_seen_unknown); + } else { + subtitle = getContext().getString(R.string.last_seen_at, DateUtils.getExtendedTimeSpanString(getContext(), timestamp)); + } + } + + recipient.addListener(this); + avatarView.setAvatar(glideRequests, recipient, false); + avatarView.setSeenRecently(dcContact != null && dcContact.wasSeenRecently()); + + nameView.setText(name); + + if (subtitle != null) { + subtitleView.setVisibility(View.VISIBLE); + subtitleView.setText(subtitle); + } else { + subtitleView.setVisibility(View.GONE); + } + } + + public void setAvatarClickListener(OnClickListener listener) { + avatarView.setAvatarClickListener(listener); + } + + public void unbind(GlideRequests glideRequests) { + if (recipient != null) { + recipient.removeListener(this); + recipient = null; + } + + avatarView.clear(glideRequests); + } + + @Override + public void onModified(final Recipient recipient) { + if (this.recipient == recipient) { + Util.runOnMain(() -> { + avatarView.setAvatar(glideRequests, recipient, false); + DcContact contact = recipient.getDcContact(); + avatarView.setSeenRecently(contact != null && contact.wasSeenRecently()); + nameView.setText(recipient.toShortString()); + }); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileFragment.java b/src/main/java/org/thoughtcrime/securesms/ProfileFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..e9c9a19c5fcc3554cf423c43c6bc495a52589390 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileFragment.java @@ -0,0 +1,323 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.qr.QrShowActivity; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class ProfileFragment extends Fragment + implements ProfileAdapter.ItemClickListener, DcEventCenter.DcEventDelegate { + + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String CONTACT_ID_EXTRA = "contact_id"; + + private static final int REQUEST_CODE_PICK_CONTACT = 2; + + private ProfileAdapter adapter; + private ActionMode actionMode; + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + + + private DcContext dcContext; + protected int chatId; + private int contactId; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + chatId = getArguments() != null ? getArguments().getInt(CHAT_ID_EXTRA, -1) : -1; + contactId = getArguments().getInt(CONTACT_ID_EXTRA, -1); + dcContext = DcHelper.getContext(requireContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.profile_fragment, container, false); + adapter = new ProfileAdapter(this, GlideApp.with(this), this); + + RecyclerView list = ViewUtil.findById(view, R.id.recycler_view); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(list); + + list.setAdapter(adapter); + list.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); + + update(); + + DcEventCenter eventCenter = DcHelper.getEventCenter(requireContext()); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + return view; + } + + @Override + public void onDestroyView() { + DcHelper.getEventCenter(requireContext()).removeObservers(this); + super.onDestroyView(); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + update(); + } + + private void update() + { + int[] memberList = null; + DcChatlist sharedChats = null; + + DcChat dcChat = null; + DcContact dcContact = null; + if (contactId>0) { dcContact = dcContext.getContact(contactId); } + if (chatId>0) { dcChat = dcContext.getChat(chatId); } + + if(dcChat!=null && dcChat.isMultiUser()) { + memberList = dcContext.getChatContacts(chatId); + } + else if(contactId>0 && contactId!=DcContact.DC_CONTACT_ID_SELF) { + sharedChats = dcContext.getChatlist(0, null, contactId); + } + + adapter.changeData(memberList, dcContact, sharedChats, dcChat); + } + + + // handle events + // ========================================================================= + + @Override + public void onSettingsClicked(int settingsId) { + switch(settingsId) { + case ProfileAdapter.ITEM_ALL_MEDIA_BUTTON: + if (chatId > 0) { + Intent intent = new Intent(getActivity(), AllMediaActivity.class); + intent.putExtra(AllMediaActivity.CHAT_ID_EXTRA, chatId); + startActivity(intent); + } + break; + case ProfileAdapter.ITEM_SEND_MESSAGE_BUTTON: + onSendMessage(); + break; + case ProfileAdapter.ITEM_INTRODUCED_BY: + onVerifiedByClicked(); + break; + } + } + + @Override + public void onStatusLongClicked() { + Context context = requireContext(); + new AlertDialog.Builder(context) + .setTitle(R.string.pref_default_status_label) + .setItems(new CharSequence[]{ + context.getString(R.string.menu_copy_to_clipboard) + }, + (dialogInterface, i) -> { + Util.writeTextToClipboard(context, adapter.getStatusText()); + Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + @Override + public void onMemberLongClicked(int contactId) { + if (contactId>DcContact.DC_CONTACT_ID_LAST_SPECIAL || contactId==DcContact.DC_CONTACT_ID_SELF) { + if (actionMode==null) { + DcChat dcChat = dcContext.getChat(chatId); + if (dcChat.canSend() && dcChat.isEncrypted()) { + adapter.toggleMemberSelection(contactId); + actionMode = ((AppCompatActivity) requireActivity()).startSupportActionMode(actionModeCallback); + } + } else { + onMemberClicked(contactId); + } + } + } + + @Override + public void onMemberClicked(int contactId) { + if (actionMode!=null) { + if (contactId>DcContact.DC_CONTACT_ID_LAST_SPECIAL || contactId==DcContact.DC_CONTACT_ID_SELF) { + adapter.toggleMemberSelection(contactId); + if (adapter.getSelectedMembersCount() == 0) { + actionMode.finish(); + actionMode = null; + } else { + actionMode.setTitle(String.valueOf(adapter.getSelectedMembersCount())); + } + } + } + else if(contactId==DcContact.DC_CONTACT_ID_ADD_MEMBER) { + onAddMember(); + } + else if(contactId==DcContact.DC_CONTACT_ID_QR_INVITE) { + onQrInvite(); + } + else if(contactId>DcContact.DC_CONTACT_ID_LAST_SPECIAL) { + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, contactId); + startActivity(intent); + } + } + + @Override + public void onAvatarClicked() { + ProfileActivity activity = (ProfileActivity)getActivity(); + activity.onEnlargeAvatar(); + } + + public void onAddMember() { + DcChat dcChat = dcContext.getChat(chatId); + Intent intent = new Intent(getContext(), ContactMultiSelectionActivity.class); + ArrayList preselectedContacts = new ArrayList<>(); + for (int memberId : dcContext.getChatContacts(chatId)) { + preselectedContacts.add(memberId); + } + intent.putExtra(ContactSelectionListFragment.PRESELECTED_CONTACTS, preselectedContacts); + startActivityForResult(intent, REQUEST_CODE_PICK_CONTACT); + } + + public void onQrInvite() { + Intent qrIntent = new Intent(getContext(), QrShowActivity.class); + qrIntent.putExtra(QrShowActivity.CHAT_ID, chatId); + startActivity(qrIntent); + } + + @Override + public void onSharedChatClicked(int chatId) { + Intent intent = new Intent(getContext(), ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + requireContext().startActivity(intent); + requireActivity().finish(); + } + + private void onVerifiedByClicked() { + DcContact dcContact = dcContext.getContact(contactId); + int verifierId = dcContact.getVerifierId(); + if (verifierId != 0 && verifierId != DcContact.DC_CONTACT_ID_SELF) { + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, verifierId); + startActivity(intent); + } + } + + private void onSendMessage() { + DcContact dcContact = dcContext.getContact(contactId); + int chatId = dcContext.createChatByContactId(dcContact.getId()); + if (chatId != 0) { + Intent intent = new Intent(getActivity(), ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + requireActivity().startActivity(intent); + requireActivity().finish(); + } + } + + private class ActionModeCallback implements ActionMode.Callback { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.profile_context, menu); + menu.findItem(R.id.delete).setVisible(true); + menu.findItem(R.id.details).setVisible(false); + menu.findItem(R.id.show_in_chat).setVisible(false); + menu.findItem(R.id.save).setVisible(false); + menu.findItem(R.id.share).setVisible(false); + menu.findItem(R.id.menu_resend).setVisible(false); + menu.findItem(R.id.menu_select_all).setVisible(false); + mode.setTitle("1"); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + if (menuItem.getItemId() == R.id.delete) { + final Collection toDelIds = adapter.getSelectedMembers(); + StringBuilder readableToDelList = new StringBuilder(); + for (Integer toDelId : toDelIds) { + if (readableToDelList.length() > 0) { + readableToDelList.append(", "); + } + readableToDelList.append(dcContext.getContact(toDelId).getDisplayName()); + } + DcChat dcChat = dcContext.getChat(chatId); + AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setPositiveButton(R.string.remove_desktop, (d, which) -> { + for (Integer toDelId : toDelIds) { + dcContext.removeContactFromChat(chatId, toDelId); + } + mode.finish(); + }) + .setNegativeButton(android.R.string.cancel, null) + .setMessage(getString(dcChat.isOutBroadcast() ? R.string.ask_remove_from_channel : R.string.ask_remove_members, readableToDelList)) + .show(); + Util.redPositiveButton(dialog); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + adapter.clearSelection(); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode==REQUEST_CODE_PICK_CONTACT && resultCode==Activity.RESULT_OK && data!=null) { + List selected = data.getIntegerArrayListExtra(ContactMultiSelectionActivity.CONTACTS_EXTRA); + if(selected == null) return; + Util.runOnAnyBackgroundThread(() -> { + for (Integer contactId : selected) { + if (contactId!=null) { + dcContext.addContactToChat(chatId, contactId); + } + } + }); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileStatusItem.java b/src/main/java/org/thoughtcrime/securesms/ProfileStatusItem.java new file mode 100644 index 0000000000000000000000000000000000000000..4e55e046c3d6106f50d65d7da3604ef47d1192bd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileStatusItem.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.text.SpannableString; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.appcompat.widget.AppCompatTextView; + +import org.thoughtcrime.securesms.util.Linkifier; +import org.thoughtcrime.securesms.util.LongClickMovementMethod; + +public class ProfileStatusItem extends LinearLayout { + + private AppCompatTextView statusTextView; + private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + + public ProfileStatusItem(Context context) { + super(context); + } + + public ProfileStatusItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + statusTextView = findViewById(R.id.status_text); + statusTextView.setOnLongClickListener(passthroughClickListener); + statusTextView.setOnClickListener(passthroughClickListener); + statusTextView.setMovementMethod(LongClickMovementMethod.getInstance(getContext())); + } + + public void set(String status) { + statusTextView.setText(Linkifier.linkify(new SpannableString(status))); + } + + private class PassthroughClickListener implements View.OnLongClickListener, View.OnClickListener { + + @Override + public boolean onLongClick(View v) { + if (statusTextView.hasSelection()) { + return false; + } + performLongClick(); + return true; + } + + @Override + public void onClick(View v) { + performClick(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ProfileTextItem.java b/src/main/java/org/thoughtcrime/securesms/ProfileTextItem.java new file mode 100644 index 0000000000000000000000000000000000000000..73b440510ff30331fc7ba1d2741e34ed8bf28b78 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ProfileTextItem.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.util.ResUtil; + +public class ProfileTextItem extends LinearLayout { + + private TextView labelView; + private @Nullable TextView valueView; + + public ProfileTextItem(Context context) { + super(context); + } + + public ProfileTextItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + labelView = findViewById(R.id.label); + valueView = findViewById(R.id.value); + } + + public void set(String label, int icon, boolean tint) { + labelView.setText(label); + + if (icon != 0) { + Drawable orgDrawable = ContextCompat.getDrawable(getContext(), icon); + if (orgDrawable != null) { + Drawable drawable = orgDrawable.mutate(); // avoid global state modification and showing eg. app-icon tinted also elsewhere + drawable = DrawableCompat.wrap(drawable); + if (tint) { + int color = ResUtil.getColor(getContext(), R.attr.colorAccent); + DrawableCompat.setTint(drawable, color); + } + labelView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); + } + } + } + + public void setValue(String value) { + if (valueView != null) { + valueView.setText(value); + valueView.setVisibility(View.VISIBLE); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/ResolveMediaTask.java b/src/main/java/org/thoughtcrime/securesms/ResolveMediaTask.java new file mode 100644 index 0000000000000000000000000000000000000000..cdb64f2a3a940c626c4fcf1a1ae1cfaf69b9f58c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ResolveMediaTask.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.util.MediaUtil.getMimeType; + +import android.app.Activity; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.OpenableColumns; +import android.util.Log; + +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.HashSet; + +import de.cketti.safecontentresolver.SafeContentResolver; + +public class ResolveMediaTask extends AsyncTask { + + private static final String TAG = ResolveMediaTask.class.getSimpleName(); + + interface OnMediaResolvedListener { + void onMediaResolved(Uri uri); + } + + private final WeakReference contextRef; + private final WeakReference listenerWeakReference; + + private static final HashSet instances = new HashSet<>(); + + ResolveMediaTask(Activity activityContext, ResolveMediaTask.OnMediaResolvedListener listener) { + this.contextRef = new WeakReference<>(activityContext); + this.listenerWeakReference = new WeakReference<>(listener); + instances.add(this); + } + + @Override + protected Uri doInBackground(Uri... uris) { + try { + Uri uri = uris[0]; + if (uris.length != 1 || uri == null) { + return null; + } + + InputStream inputStream; + String fileName = null; + Long fileSize = null; + + SafeContentResolver safeContentResolver = SafeContentResolver.newInstance(contextRef.get()); + inputStream = safeContentResolver.openInputStream(uri); + + if (inputStream == null) { + return null; + } + + Cursor cursor = contextRef.get().getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, null, null, null); + + try { + if (cursor != null && cursor.moveToFirst()) { + try { + fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } catch (IllegalArgumentException e) { + Log.w(TAG, e); + } + } + } finally { + if (cursor != null) cursor.close(); + } + + if (fileName == null) { + fileName = uri.getLastPathSegment(); + } + + String mimeType = getMimeType(contextRef.get(), uri); + return PersistentBlobProvider.getInstance().create(contextRef.get(), inputStream, mimeType, fileName, fileSize); + } catch (NullPointerException | FileNotFoundException ioe) { + Log.w(TAG, ioe); + return null; + } + } + + @Override + protected void onPostExecute(Uri uri) { + instances.remove(this); + if (!this.isCancelled()) { + listenerWeakReference.get().onMediaResolved(uri); + } + } + + @Override + protected void onCancelled() { + instances.remove(this); + super.onCancelled(); + listenerWeakReference.get().onMediaResolved(null); + + } + + public static boolean isExecuting() { + return !instances.isEmpty(); + } + + public static void cancelTasks() { + for (ResolveMediaTask task : instances) { + task.cancel(true); + } + } + + private boolean hasFileScheme(Uri uri) { + if (uri == null) { + return false; + } + return "file".equals(uri.getScheme()); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/SetStartingPositionLinearLayoutManager.java b/src/main/java/org/thoughtcrime/securesms/SetStartingPositionLinearLayoutManager.java new file mode 100644 index 0000000000000000000000000000000000000000..2bc69859384982e198f9e8ff7b416159a187ca5a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/SetStartingPositionLinearLayoutManager.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.os.Parcelable; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * Like LinearLayoutManager but you can set a starting position + */ +class SetStartingPositionLinearLayoutManager extends LinearLayoutManager { + + private int pendingStartingPos = -1; + + SetStartingPositionLinearLayoutManager(Context context, int vertical, boolean b) { + super(context, vertical, b); + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + if (pendingStartingPos != -1 && state.getItemCount() > 0) { + // scrollToPositionWithOffset(mPendingTargetPos, 0) would also do the job but the target item + // would be at the bottom of the screen, not at the top + int position = pendingStartingPos + 1; + if (position < state.getItemCount()) { + scrollToPositionWithOffset(pendingStartingPos + 1, getHeight() - ViewUtil.dpToPx(10)); + } else { + // pendingTargetPos is the top-most item + scrollToPosition(pendingStartingPos); + } + pendingStartingPos = -1; + } + + super.onLayoutChildren(recycler, state); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + pendingStartingPos = -1; + super.onRestoreInstanceState(state); + } + + public void setStartingPosition(int position) { + pendingStartingPos = position; + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/src/main/java/org/thoughtcrime/securesms/ShareActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..f661934f1bafadb400dde98667e6ba02a7b06696 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ShareActivity.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.pm.ShortcutManagerCompat; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.MailtoUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ShareUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * An activity to quickly share content with chats + * + * @author Jake McGinty + */ +public class ShareActivity extends PassphraseRequiredActionBarActivity implements ResolveMediaTask.OnMediaResolvedListener +{ + private static final String TAG = ShareActivity.class.getSimpleName(); + + public static final String EXTRA_ACC_ID = "acc_id"; + public static final String EXTRA_CHAT_ID = "chat_id"; + + private ArrayList resolvedExtras; + private DcContext dcContext; + private boolean isResolvingUrisOnMainThread; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + dcContext = DcHelper.getContext(this); + + setContentView(R.layout.share_activity); + + initializeToolbar(); + initializeMedia(); + } + + @Override + protected void onNewIntent(Intent intent) { + Log.w(TAG, "onNewIntent()"); + super.onNewIntent(intent); + setIntent(intent); + initializeMedia(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + ResolveMediaTask.cancelTasks(); + } + + private void initializeToolbar() { + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + private void initializeMedia() { + resolvedExtras = new ArrayList<>(); + + List streamExtras = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(getIntent().getAction()) && + getIntent().getParcelableExtra(Intent.EXTRA_STREAM) != null) { + Uri uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); + streamExtras.add(uri); + } else if (getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM) != null) { + streamExtras = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM); + } else { + Uri uri = getIntent().getData(); + if (MailtoUtil.isMailto(uri)) { + String[] extraEmail = getIntent().getStringArrayExtra(Intent.EXTRA_EMAIL); + if (extraEmail == null || extraEmail.length == 0) { + getIntent().putExtra(Intent.EXTRA_EMAIL, MailtoUtil.getRecipients(uri)); + } + String text = getIntent().getStringExtra(Intent.EXTRA_TEXT); + if (text == null || text.isEmpty()) { + getIntent().putExtra(Intent.EXTRA_TEXT, MailtoUtil.getText(uri)); + } + } else if (uri != null) { + streamExtras.add(uri); + } + } + + if (needsFilePermission(streamExtras)) { + if (Permissions.hasAll(this, Manifest.permission.READ_EXTERNAL_STORAGE)) { + resolveUris(streamExtras); + } else { + requestPermissionForFiles(streamExtras); + } + } else { + resolveUris(streamExtras); + } + } + + private boolean needsFilePermission(List uris) { + for(Uri uri : uris) { + // uri may be null, however, hasFileScheme() just returns false in this case + if (hasFileScheme(uri)) { + return true; + } + } + return false; + } + + private void requestPermissionForFiles(List streamExtras) { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .alwaysGrantOnSdk33() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> resolveUris(streamExtras)) + .onAnyDenied(this::abortShare) + .execute(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void abortShare() { + Toast.makeText(this, R.string.share_abort, Toast.LENGTH_LONG).show(); + finish(); + } + + private void resolveUris(List streamExtras) { + isResolvingUrisOnMainThread = true; + for (Uri streamExtra : streamExtras) { + if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { + resolvedExtras.add(streamExtra); + } else { + new ResolveMediaTask(this, this).execute(streamExtra); + } + } + + if (!ResolveMediaTask.isExecuting()) { + handleResolvedMedia(getIntent()); + } + isResolvingUrisOnMainThread = false; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + return false; + } + + @Override + public void onMediaResolved(Uri uri) { + if (uri != null) { + resolvedExtras.add(uri); + } + + if (!ResolveMediaTask.isExecuting() && !isResolvingUrisOnMainThread) { + handleResolvedMedia(getIntent()); + } + } + + private void handleResolvedMedia(Intent intent) { + int accId = intent.getIntExtra(EXTRA_ACC_ID, -1); + int chatId = intent.getIntExtra(EXTRA_CHAT_ID, -1); + + // the intent coming from shortcuts in the share selector might not include the custom extras but the shortcut ID + String shortcutId = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID); + if ((chatId == -1 || accId == -1) && shortcutId != null && shortcutId.startsWith("chat-")) { + String[] args = shortcutId.split("-"); + if (args.length == 3) { + accId = Integer.parseInt(args[1]); + chatId = Integer.parseInt(args[2]); + } + } + + String[] extraEmail = getIntent().getStringArrayExtra(Intent.EXTRA_EMAIL); + /* + usually, external app will try to start "e-mail sharing" intent, providing it: + 1. address(s), packed in array, marked as Intent.EXTRA_EMAIL - mandatory + 2. shared content (files, pics, video), packed in Intent.EXTRA_STREAM - optional + + here is a sample code to trigger this routine from within external app: + + try { + Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.fromParts( + "mailto", "someone@example.com", null)); + File f = new File(getFilesDir() + "/somebinaryfile.bin"); + f.createNewFile(); + f.setReadable(true, false); + byte[] b = new byte[1024]; + new Random().nextBytes(b); + FileOutputStream fOut = new FileOutputStream(f); + DataOutputStream dataStream = new DataOutputStream(fOut); + dataStream.write(b); + dataStream.close(); + + Uri sharedURI = FileProvider.getUriForFile(context, context.getApplicationContext().getPackageName() + ".provider", f); + emailIntent.setAction(Intent.ACTION_SEND); + emailIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{"someone@example.com"}); + emailIntent.setType("text/plain"); + emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + emailIntent.putExtra(Intent.EXTRA_STREAM, sharedURI); + + // to EXPLICITLY fire DC's sharing activity: + // emailIntent.setComponent(new ComponentName("com.b44t.messenger.beta", "org.thoughtcrime.securesms.ShareActivity")); + + startActivity(emailIntent); + } catch (IOException e) { + e.printStackTrace(); + }catch (ActivityNotFoundException e) { + } + */ + + if(chatId == -1 && extraEmail != null && extraEmail.length > 0) { + final String addr = extraEmail[0]; + int contactId = dcContext.lookupContactIdByAddr(addr); + + if(contactId == 0) { + contactId = dcContext.createContact(null, addr); + } + + chatId = dcContext.createChatByContactId(contactId); + } + Intent composeIntent; + if (accId != -1 && chatId != -1) { + composeIntent = getBaseShareIntent(ConversationActivity.class); + composeIntent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + composeIntent.putExtra(ConversationActivity.ACCOUNT_ID_EXTRA, accId); + startActivity(composeIntent); + } else { + composeIntent = getBaseShareIntent(ConversationListRelayingActivity.class); + ShareUtil.setIsFromWebxdc(composeIntent, ShareUtil.isFromWebxdc(this)); + ConversationListRelayingActivity.start(this, composeIntent); + } + finish(); + } + + private Intent getBaseShareIntent(final @NonNull Class target) { + final Intent intent = new Intent(this, target); + + ShareUtil.setSharedTitle(intent, ShareUtil.getSharedTitle(this)); + ShareUtil.setSharedType(intent, ShareUtil.getSharedType(this)); + ShareUtil.setSharedSubject(intent, ShareUtil.getSharedSubject(this)); + ShareUtil.setSharedHtml(intent, ShareUtil.getSharedHtml(this)); + ShareUtil.setSharedUris(intent, resolvedExtras); + + String text = getIntent().getStringExtra(Intent.EXTRA_TEXT); + if (text==null) { + CharSequence cs = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT); + if (cs!=null) { + text = cs.toString(); + } + } + + if (text != null) { + ShareUtil.setSharedText(intent, text.toString()); + } + + if (resolvedExtras.size() > 0) { + Uri data = resolvedExtras.get(0); + if (data != null) { + String mimeType = getMimeType(data); + intent.setDataAndType(data, mimeType); + } + } + return intent; + } + + private String getMimeType(@Nullable Uri uri) { + if (uri != null) { + final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri); + if (mimeType != null) return mimeType; + } + return MediaUtil.getCorrectedMimeType(getIntent().getType()); + } + + private boolean hasFileScheme(Uri uri) { + if (uri==null) { + return false; + } + return "file".equals(uri.getScheme()); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/ShareLocationDialog.java b/src/main/java/org/thoughtcrime/securesms/ShareLocationDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..e251e4de20fa710e43ccf2e26a934f07b0ed4c19 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/ShareLocationDialog.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +public class ShareLocationDialog { + + public static void show(final Context context, final @NonNull ShareLocationDurationSelectionListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.title_share_location); + builder.setItems(R.array.share_location_durations, (dialog, which) -> { + final int shareLocationUnit; + + switch (which) { + default: + case 0: shareLocationUnit = 5 * 60; break; + case 1: shareLocationUnit = 30 * 60; break; + case 2: shareLocationUnit = 60 * 60; break; + case 3: shareLocationUnit = 2 * 60 * 60; break; + case 4: shareLocationUnit = 6 * 60 * 60; break; + case 5: shareLocationUnit = 12 * 60 * 60; break; + } + + listener.onSelected(shareLocationUnit); + }); + builder.setNegativeButton(R.string.cancel, null); + + builder.show(); + } + + public interface ShareLocationDurationSelectionListener { + void onSelected(int durationInSeconds); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/TransportOption.java b/src/main/java/org/thoughtcrime/securesms/TransportOption.java new file mode 100644 index 0000000000000000000000000000000000000000..50b190afda9d8132e7aaa8564557241fb74472aa --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/TransportOption.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +public class TransportOption { + + public enum Type { + NORMAL_MAIL + } + + private final int drawable; + private final @NonNull String text; + private final @NonNull String composeHint; + + public TransportOption(@NonNull Type type, + @DrawableRes int drawable, + @NonNull String text, + @NonNull String composeHint) + { + this.drawable = drawable; + this.text = text; + this.composeHint = composeHint; + } + + public @NonNull Type getType() { + return Type.NORMAL_MAIL; + } + + public @DrawableRes int getDrawable() { + return drawable; + } + + public @NonNull String getComposeHint() { + return composeHint; + } + + public @NonNull String getDescription() { + return text; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/TransportOptions.java b/src/main/java/org/thoughtcrime/securesms/TransportOptions.java new file mode 100644 index 0000000000000000000000000000000000000000..7761e1dcb0f0ff9b2f1b03b427642de1aee9acbe --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/TransportOptions.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms; + +import static org.thoughtcrime.securesms.TransportOption.Type; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.guava.Optional; + +import java.util.LinkedList; +import java.util.List; + +public class TransportOptions { + private final List listeners = new LinkedList<>(); + private final Context context; + private final List enabledTransports; + + private Type defaultTransportType = Type.NORMAL_MAIL; + private Optional selectedOption = Optional.absent(); + + public TransportOptions(Context context) { + this.context = context; + this.enabledTransports = initializeAvailableTransports(); + } + + public void reset() { + List transportOptions = initializeAvailableTransports(); + + this.enabledTransports.clear(); + this.enabledTransports.addAll(transportOptions); + + if (selectedOption.isPresent() && !isEnabled(selectedOption.get())) { + setSelectedTransport(null); + } else { + this.defaultTransportType = Type.NORMAL_MAIL; + + notifyTransportChangeListeners(); + } + } + + public void setDefaultTransport(Type type) { + this.defaultTransportType = type; + + if (!selectedOption.isPresent()) { + notifyTransportChangeListeners(); + } + } + + public void setSelectedTransport(@Nullable TransportOption transportOption) { + this.selectedOption = Optional.fromNullable(transportOption); + notifyTransportChangeListeners(); + } + + public @NonNull TransportOption getSelectedTransport() { + if (selectedOption.isPresent()) return selectedOption.get(); + + for (TransportOption transportOption : enabledTransports) { + if (transportOption.getType() == defaultTransportType) { + return transportOption; + } + } + + throw new AssertionError("No options of default type!"); + } + + public List getEnabledTransports() { + return enabledTransports; + } + + public void addOnTransportChangedListener(OnTransportChangedListener listener) { + this.listeners.add(listener); + } + + private List initializeAvailableTransports() { + List results = new LinkedList<>(); + + results.add(new TransportOption(Type.NORMAL_MAIL, R.drawable.ic_send_sms_white_24dp, + context.getString(R.string.menu_send), + context.getString(R.string.chat_input_placeholder))); + + return results; + } + + private void notifyTransportChangeListeners() { + for (OnTransportChangedListener listener : listeners) { + listener.onChange(getSelectedTransport(), selectedOption.isPresent()); + } + } + + private boolean isEnabled(TransportOption transportOption) { + for (TransportOption option : enabledTransports) { + if (option.equals(transportOption)) return true; + } + + return false; + } + + public interface OnTransportChangedListener { + public void onChange(TransportOption newTransport, boolean manuallySelected); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java b/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..4ed9c7f6b5965d3583a13971a51095116fd9a128 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.List; + +public class TransportOptionsAdapter extends BaseAdapter { + + private final LayoutInflater inflater; + + private List enabledTransports; + + public TransportOptionsAdapter(@NonNull Context context, + @NonNull List enabledTransports) + { + super(); + this.inflater = LayoutInflater.from(context); + this.enabledTransports = enabledTransports; + } + + public void setEnabledTransports(List enabledTransports) { + this.enabledTransports = enabledTransports; + } + + @Override + public int getCount() { + return enabledTransports.size(); + } + + @Override + public Object getItem(int position) { + return enabledTransports.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = inflater.inflate(R.layout.transport_selection_list_item, parent, false); + } + + TransportOption transport = (TransportOption) getItem(position); + ImageView imageView = ViewUtil.findById(convertView, R.id.icon); + TextView textView = ViewUtil.findById(convertView, R.id.text); + TextView subtextView = ViewUtil.findById(convertView, R.id.subtext); + + imageView.setImageResource(transport.getDrawable()); + textView.setText(transport.getDescription()); + subtextView.setVisibility(View.GONE); + + return convertView; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java b/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java new file mode 100644 index 0000000000000000000000000000000000000000..63e8096f101c11d114bb6c330dcdb84fa418d7dd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.ListPopupWindow; + +import java.util.LinkedList; +import java.util.List; + +public class TransportOptionsPopup extends ListPopupWindow implements ListView.OnItemClickListener { + + private final TransportOptionsAdapter adapter; + private final SelectedListener listener; + + public TransportOptionsPopup(@NonNull Context context, @NonNull View anchor, @NonNull SelectedListener listener) { + super(context); + this.listener = listener; + this.adapter = new TransportOptionsAdapter(context, new LinkedList()); + + setVerticalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_yoff)); + setHorizontalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_xoff)); + setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + setModal(true); + setAnchorView(anchor); + setAdapter(adapter); + setContentWidth(context.getResources().getDimensionPixelSize(R.dimen.transport_selection_popup_width)); + + setOnItemClickListener(this); + } + + public void display(List enabledTransports) { + adapter.setEnabledTransports(enabledTransports); + adapter.notifyDataSetChanged(); + show(); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + listener.onSelected((TransportOption)adapter.getItem(position)); + } + + public interface SelectedListener { + void onSelected(TransportOption option); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/Unbindable.java b/src/main/java/org/thoughtcrime/securesms/Unbindable.java new file mode 100644 index 0000000000000000000000000000000000000000..3dd5cd8cc06c9a9a76a5d07ed1ebdf05fa65050d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/Unbindable.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms; + +public interface Unbindable { + public void unbind(); +} diff --git a/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java b/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..b092e3c621e135fda6beb28e0620db0f94e8c8f7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java @@ -0,0 +1,331 @@ +package org.thoughtcrime.securesms; + +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SearchView; +import androidx.webkit.ProxyConfig; +import androidx.webkit.ProxyController; +import androidx.webkit.WebSettingsCompat; +import androidx.webkit.WebViewFeature; + +import org.thoughtcrime.securesms.qr.QrCodeHandler; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.net.IDN; + +public class WebViewActivity extends PassphraseRequiredActionBarActivity + implements SearchView.OnQueryTextListener, + WebView.FindListener +{ + private static final String TAG = WebViewActivity.class.getSimpleName(); + + protected WebView webView; + + /** Return true the window content should display fullscreen/edge-to-edge ex. in the integrated maps app */ + protected boolean immersiveMode() { return false; } + + protected boolean shouldAskToOpenLink() { return false; } + + protected void toggleFakeProxy(boolean enable) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { + if (enable) { + // Set proxy to non-routable address. + ProxyConfig proxyConfig = new ProxyConfig.Builder() + .removeImplicitRules() + .addProxyRule("0.0.0.0") + .build(); + ProxyController.getInstance().setProxyOverride(proxyConfig, Runnable::run, () -> Log.i(TAG, "Set WebView proxy.")); + } else { + ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> Log.i(TAG, "Cleared WebView proxy.")); + } + } else { + Log.w(TAG, "Cannot " + (enable? "set": "clear") + " WebView proxy."); + } + } + + @Override + protected void onCreate(Bundle state, boolean ready) { + setContentView(R.layout.web_view_activity); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + webView = findViewById(R.id.webview); + + if(immersiveMode()) { + // set a shadow in the status bar to make it more readable + findViewById(R.id.status_bar_background).setBackgroundResource(R.drawable.search_toolbar_shadow); + } else { + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.content_container)); + } + + webView.setWebViewClient(new WebViewClient() { + // IMPORTANT: this is will likely not be called inside iframes unless target=_blank is used in the anchor/link tag. + // `shouldOverrideUrlLoading()` is called when the user clicks a URL, + // returning `true` causes the WebView to abort loading the URL, + // returning `false` causes the WebView to continue loading the URL as usual. + // the method is not called for POST request nor for on-page-links. + // + // nb: from API 24, `shouldOverrideUrlLoading(String)` is deprecated and + // `shouldOverrideUrlLoading(WebResourceRequest)` shall be used. + // the new one has the same functionality, and the old one still exist, + // so, to support all systems, for now, using the old one seems to be the simplest way. + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url != null) { + String schema = url.split(":")[0].toLowerCase(); + switch (schema) { + case "http": + case "https": + case "gemini": + case "tel": + case "sms": + case "mailto": + case "openpgp4fpr": + case "geo": + case "dcaccount": + case "dclogin": + return openOnlineUrl(url); + } + } + return true; // returning `true` aborts loading + } + + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, String url) { + WebResourceResponse res = interceptRequest(url); + if (res!=null) { + return res; + } + return super.shouldInterceptRequest(view, url); + } + + @Override + @RequiresApi(21) + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + WebResourceResponse res = interceptRequest(request.getUrl().toString()); + if (res!=null) { + return res; + } + return super.shouldInterceptRequest(view, request); + } + }); + webView.setFindListener(this); + + // disable "safe browsing" as this has privacy issues, + // eg. at least false positives are sent to the "Safe Browsing Lookup API". + // as all URLs opened in the WebView are local anyway, + // "safe browsing" will never be able to report issues, so it can be disabled. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + webView.getSettings().setSafeBrowsingEnabled(false); + } + } + + protected void setForceDark() { + if (Build.VERSION.SDK_INT <= 32 && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + // needed for older API (tested on android7) that do not set `color-scheme` without the following hint + WebSettingsCompat.setForceDark(webView.getSettings(), + DynamicTheme.isDarkTheme(this) ? WebSettingsCompat.FORCE_DARK_ON : WebSettingsCompat.FORCE_DARK_OFF); + } + } + + @Override + protected void onPause() { + super.onPause(); + webView.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + webView.onResume(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + webView.destroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.web_view, menu); + + try { + MenuItem searchItem = menu.findItem(R.id.menu_search_web_view); + searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(final MenuItem item) { + searchMenu = menu; + WebViewActivity.this.lastQuery = ""; + WebViewActivity.this.makeSearchMenuVisible(menu, searchItem, true); + return true; + } + + @Override + public boolean onMenuItemActionCollapse(final MenuItem item) { + WebViewActivity.this.makeSearchMenuVisible(menu, searchItem, false); + return true; + } + }); + SearchView searchView = (SearchView) searchItem.getActionView(); + searchView.setOnQueryTextListener(this); + searchView.setQueryHint(getString(R.string.search)); + searchView.setIconifiedByDefault(true); + + // hide the [X] beside the search field - this is too much noise, search can be aborted eg. by "back" + ImageView closeBtn = searchView.findViewById(R.id.search_close_btn); + if (closeBtn!=null) { + closeBtn.setEnabled(false); + closeBtn.setImageDrawable(null); + } + } catch (Exception e) { + Log.e(TAG, "cannot set up web-view-search: ", e); + } + + super.onPrepareOptionsMenu(menu); + return true; + } + + + // search + + private Menu searchMenu = null; + private String lastQuery = ""; + private Toast lastToast = null; + + private void updateResultCounter(int curr, int total) { + if (searchMenu!=null) { + MenuItem item = searchMenu.findItem(R.id.menu_search_counter); + if (curr!=-1) { + item.setTitle(String.format("%d/%d", total==0? 0 : curr+1, total)); + item.setVisible(true); + } else { + item.setVisible(false); + } + } + } + + @Override + public boolean onQueryTextSubmit(String query) { + return true; // action handled by listener + } + + @Override + public boolean onQueryTextChange(String query) { + String normQuery = query.trim(); + lastQuery = normQuery; + if (lastToast!=null) { + lastToast.cancel(); + lastToast = null; + } + webView.findAllAsync(normQuery); + return true; // action handled by listener + } + + @Override + public void onFindResultReceived (int activeMatchOrdinal, int numberOfMatches, boolean isDoneCounting) + { + if (isDoneCounting) { + if (numberOfMatches>0) { + updateResultCounter(activeMatchOrdinal, numberOfMatches); + } else { + if (lastQuery.isEmpty()) { + updateResultCounter(-1, 0); // hide + } else { + String msg = getString(R.string.search_no_result_for_x, lastQuery); + if (lastToast != null) { + lastToast.cancel(); + } + lastToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT); + lastToast.show(); + updateResultCounter(0, 0); // show as "0/0" + } + } + } + } + + + // other actions + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.menu_search_up) { + if (lastQuery.isEmpty()) { + webView.scrollTo(0, 0); + } else { + webView.findNext(false); + } + return true; + } else if (itemId == R.id.menu_search_down) { + if (lastQuery.isEmpty()) { + webView.scrollTo(0, 1000000000); + } else { + webView.findNext(true); + } + return true; + } + return false; + } + + // onBackPressed() can be overwritten by derived classes as needed. + // the default behavior (close the activity) is just fine eg. for Webxdc, Connectivity, HTML-mails + + protected boolean openOnlineUrl(String url) { + // invite-links should be handled directly + String schema = url.split(":")[0].toLowerCase(); + if (schema.equals("openpgp4fpr") || url.startsWith("https://" + Util.INVITE_DOMAIN + "/")) { + new QrCodeHandler(this).handleQrData(url); + return true; // abort internal loading + } + + if (shouldAskToOpenLink()) { + new AlertDialog.Builder(this) + .setTitle(R.string.open_url_confirmation) + .setMessage(IDN.toASCII(url)) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.open, (d, w) -> IntentUtils.showInBrowser(this, url)) + .setNegativeButton(R.string.global_menu_edit_copy_desktop, (d, w) -> { + Util.writeTextToClipboard(this, url); + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show(); + }) + .show(); + } else { + IntentUtils.showInBrowser(this, url); + } + + // returning `true` causes the WebView to abort loading + return true; + } + + protected WebResourceResponse interceptRequest(String url) { + return null; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..ed4a286c006db226c3ac19ee16eb8d9a88bd83c7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java @@ -0,0 +1,670 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.speech.tts.TextToSpeech; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.webkit.JavascriptInterface; +import android.webkit.MimeTypeMap; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.ActionBar; +import androidx.core.app.TaskStackBuilder; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; +import com.google.common.base.Charsets; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate { + private static final String TAG = WebxdcActivity.class.getSimpleName(); + private static final String EXTRA_ACCOUNT_ID = "accountId"; + private static final String EXTRA_APP_MSG_ID = "appMessageId"; + private static final String EXTRA_HIDE_ACTION_BAR = "hideActionBar"; + private static final String EXTRA_HREF = "href"; + private static final int REQUEST_CODE_FILE_PICKER = 51426; + private static long lastOpenTime = 0; + + private ValueCallback filePathCallback; + private DcContext dcContext; + private Rpc rpc; + private DcMsg dcAppMsg; + private String baseURL; + private String sourceCodeUrl = ""; + private String selfAddr; + private int sendUpdateMaxSize; + private int sendUpdateInterval; + private boolean internetAccess = false; + private boolean hideActionBar = false; + + private TextToSpeech tts; + + public static void openMaps(Context context, int chatId) { + openMaps(context, chatId, ""); + } + + public static void openMaps(Context context, int chatId, String href) { + DcContext dcContext = DcHelper.getContext(context); + int msgId = 0; + final int mapsVersion = 2; + if (dcContext.getConfigInt("ui.maps_version") >= mapsVersion) { + msgId = dcContext.initWebxdcIntegration(chatId); + } + if (msgId == 0) { + try { + InputStream inputStream = context.getResources().getAssets().open("webxdc/maps.xdc"); + String outputFile = DcHelper.getBlobdirFile(dcContext, "maps", ".xdc"); + Util.copy(inputStream, new FileOutputStream(outputFile)); + dcContext.setWebxdcIntegration(outputFile); + msgId = dcContext.initWebxdcIntegration(chatId); + } catch (IOException e) { + e.printStackTrace(); + } + if (msgId == 0) { + Toast.makeText(context, "Cannot get maps.xdc, see log for details.", Toast.LENGTH_LONG).show(); + return; + } + dcContext.setConfigInt("ui.maps_version", mapsVersion); + } + openWebxdcActivity(context, msgId, true, href); + } + + public static void openWebxdcActivity(Context context, DcMsg instance) { + openWebxdcActivity(context, instance, ""); + } + + public static void openWebxdcActivity(Context context, @NonNull DcMsg instance, String href) { + openWebxdcActivity(context, instance.getId(), false, href); + } + + public static void openWebxdcActivity(Context context, int msgId, boolean hideActionBar, String href) { + if (!Util.isClickedRecently()) { + context.startActivity(getWebxdcIntent(context, msgId, hideActionBar, href)); + } + } + + private static Intent getWebxdcIntent(Context context, int msgId, boolean hideActionBar, String href) { + DcContext dcContext = DcHelper.getContext(context); + Intent intent = new Intent(context, WebxdcActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(EXTRA_ACCOUNT_ID, dcContext.getAccountId()); + intent.putExtra(EXTRA_APP_MSG_ID, msgId); + intent.putExtra(EXTRA_HIDE_ACTION_BAR, hideActionBar); + intent.putExtra(EXTRA_HREF, href); + return intent; + } + + private static Intent[] getWebxdcIntentWithParentStack(Context context, int msgId) { + DcContext dcContext = DcHelper.getContext(context); + + final Intent chatIntent = new Intent(context, ConversationActivity.class) + .putExtra(ConversationActivity.CHAT_ID_EXTRA, dcContext.getMsg(msgId).getChatId()) + .setAction(Intent.ACTION_VIEW); + + final Intent webxdcIntent = getWebxdcIntent(context, msgId, false, ""); + + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(chatIntent) + .addNextIntent(webxdcIntent) + .getIntents(); + } + + @Override + protected boolean immersiveMode() { return hideActionBar; } + + @Override + protected void onCreate(Bundle state, boolean ready) { + Bundle b = getIntent().getExtras(); + hideActionBar = b.getBoolean(EXTRA_HIDE_ACTION_BAR, false); + + super.onCreate(state, ready); + rpc = DcHelper.getRpc(this); + initTTS(); + + + webView.setWebChromeClient(new WebChromeClient() { + @Override + @RequiresApi(21) + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) { + if (WebxdcActivity.this.filePathCallback != null) { + WebxdcActivity.this.filePathCallback.onReceiveValue(null); + } + WebxdcActivity.this.filePathCallback = filePathCallback; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE); + WebxdcActivity.this.startActivityForResult(Intent.createChooser(intent, getString(R.string.select)), REQUEST_CODE_FILE_PICKER); + return true; + } + }); + + DcEventCenter eventCenter = DcHelper.getEventCenter(WebxdcActivity.this.getApplicationContext()); + eventCenter.addObserver(DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_WEBXDC_REALTIME_DATA, this); + + int appMessageId = b.getInt(EXTRA_APP_MSG_ID); + int accountId = b.getInt(EXTRA_ACCOUNT_ID); + this.dcContext = DcHelper.getContext(getApplicationContext()); + if (accountId != dcContext.getAccountId()) { + AccountManager.getInstance().switchAccount(getApplicationContext(), accountId); + this.dcContext = DcHelper.getContext(getApplicationContext()); + } + + this.dcAppMsg = this.dcContext.getMsg(appMessageId); + if (!this.dcAppMsg.isOk()) { + Toast.makeText(this, "Webxdc does no longer exist.", Toast.LENGTH_LONG).show(); + finish(); + return; + } + + // `msg_id` in the subdomain makes sure, different apps using same files do not share the same cache entry + // (WebView may use a global cache shared across objects). + // (a random-id would also work, but would need maintenance and does not add benefits as we regard the file-part interceptRequest() only, + // also a random-id is not that useful for debugging) + this.baseURL = "https://acc" + dcContext.getAccountId() + "-msg" + appMessageId + ".localhost"; + + final JSONObject info = this.dcAppMsg.getWebxdcInfo(); + internetAccess = JsonUtils.optBoolean(info, "internet_access"); + if ("landscape".equals(JsonUtils.optString(info, "orientation"))) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } else { + // enter fullscreen mode if necessary, + // this is needed here because if the app is opened while already in landscape mode, onConfigurationChanged() is not triggered + setScreenMode(getResources().getConfiguration()); + } + selfAddr = info.optString("self_addr"); + sendUpdateMaxSize = info.optInt("send_update_max_size"); + sendUpdateInterval = info.optInt("send_update_interval"); + + toggleFakeProxy(!internetAccess); + + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setAllowFileAccess(false); + webSettings.setBlockNetworkLoads(!internetAccess); + webSettings.setAllowContentAccess(false); + webSettings.setGeolocationEnabled(false); + webSettings.setAllowFileAccessFromFileURLs(false); + webSettings.setAllowUniversalAccessFromFileURLs(false); + webSettings.setDatabaseEnabled(true); + webSettings.setDomStorageEnabled(true); + webView.setNetworkAvailable(internetAccess); // this does not block network but sets `window.navigator.isOnline` in js land + webView.addJavascriptInterface(new InternalJSApi(), "InternalJSApi"); + + String extraHref = b.getString(EXTRA_HREF, ""); + if (TextUtils.isEmpty(extraHref)) { + extraHref = "index.html"; + } + + String href = baseURL + "/" + extraHref; + String encodedHref = ""; + try { + encodedHref = URLEncoder.encode(href, Charsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + + webView.loadUrl(this.baseURL + "/webxdc_bootstrap324567869.html?i=1&href=" + encodedHref); + + Util.runOnAnyBackgroundThread(() -> { + final DcChat chat = dcContext.getChat(dcAppMsg.getChatId()); + Util.runOnMain(() -> { + updateTitleAndMenu(info, chat); + }); + }); + } + + @Override + public void onResume() { + super.onResume(); + DcHelper.getNotificationCenter(this).updateVisibleWebxdc(dcContext.getAccountId(), dcAppMsg.getId()); + } + + @Override + protected void onPause() { + super.onPause(); + DcHelper.getNotificationCenter(this).clearVisibleWebxdc(); + } + + @Override + protected void onDestroy() { + lastOpenTime = System.currentTimeMillis(); + DcHelper.getEventCenter(this.getApplicationContext()).removeObservers(this); + leaveRealtimeChannel(); + tts.shutdown(); + super.onDestroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + // do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed + menu.clear(); + this.getMenuInflater().inflate(R.menu.webxdc, menu); + menu.findItem(R.id.source_code).setVisible(!sourceCodeUrl.isEmpty()); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == R.id.menu_add_to_home_screen) { + addToHomeScreen(this, dcAppMsg.getId()); + return true; + } else if (itemId == R.id.webxdc_help) { + DcHelper.openHelp(this, "#webxdc"); + } else if (itemId == R.id.source_code) { + IntentUtils.showInBrowser(this, sourceCodeUrl); + return true; + } else if (itemId == R.id.show_in_chat) { + showInChat(); + return true; + } + return false; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.i(TAG, "onConfigurationChanged(" + newConfig.orientation + ")"); + super.onConfigurationChanged(newConfig); + // orientation might have changed, enter/exit fullscreen mode if needed + setScreenMode(newConfig); + } + + private void initTTS() { + tts = new TextToSpeech(this, new TextToSpeech.OnInitListener() { + @Override + public void onInit(int status) { + Log.i(TAG, "TTS Init Status: " + status); + } + }); + } + private void setScreenMode(Configuration config) { + // enter/exit fullscreen mode depending on orientation (landscape/portrait), + // on tablets there is enough height so fullscreen mode is never enabled there + boolean enable = config.orientation == Configuration.ORIENTATION_LANDSCAPE && !getResources().getBoolean(R.bool.isBigScreen); + getWindow().getDecorView().setSystemUiVisibility(enable? View.SYSTEM_UI_FLAG_FULLSCREEN : 0); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + if (hideActionBar || enable) { + actionBar.hide(); + } else { + actionBar.show(); + } + } + } + + @Override + protected boolean shouldAskToOpenLink() { return true; } + + // This is usually only called when internetAccess == true or for mailto/openpgp4fpr scheme, + // because when internetAccess == false, the page is loaded inside an iframe, + // and WebViewClient.shouldOverrideUrlLoading is not called for HTTP(S) links inside the iframe unless target=_blank is used + @Override + protected boolean openOnlineUrl(String url) { + Log.i(TAG, "openOnlineUrl: " + url); + + // if there is internet access, allow internal loading of http + if (internetAccess && url.startsWith("http")) { + // returning `false` continues loading in WebView; returning `true` let WebView abort loading + return false; + } + + return super.openOnlineUrl(url); + } + + @Override + protected WebResourceResponse interceptRequest(String rawUrl) { + Log.i(TAG, "interceptRequest: " + rawUrl); + WebResourceResponse res = null; + try { + if (rawUrl == null) { + throw new Exception("no url specified"); + } + String path = Uri.parse(rawUrl).getPath(); + if (path.equalsIgnoreCase("/webxdc.js")) { + InputStream targetStream = getResources().openRawResource(R.raw.webxdc); + res = new WebResourceResponse("text/javascript", "UTF-8", targetStream); + } else if (path.equalsIgnoreCase("/webxdc_bootstrap324567869.html")) { + InputStream targetStream = getResources().openRawResource(R.raw.webxdc_wrapper); + res = new WebResourceResponse("text/html", "UTF-8", targetStream); + } else if (path.equalsIgnoreCase("/sandboxed_iframe_rtcpeerconnection_check_5965668501706.html")) { + InputStream targetStream = getResources().openRawResource(R.raw.sandboxed_iframe_rtcpeerconnection_check); + res = new WebResourceResponse("text/html", "UTF-8", targetStream); + } else { + byte[] blob = this.dcAppMsg.getWebxdcBlob(path); + if (blob == null) { + if (internetAccess) { + return null; // do not intercept request + } + throw new Exception("\"" + path + "\" not found"); + } + String ext = MediaUtil.getFileExtensionFromUrl(path); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + if (mimeType == null) { + switch (ext) { + case "js": mimeType = "text/javascript"; break; + case "wasm": mimeType = "application/wasm"; break; + default: mimeType = "application/octet-stream"; Log.i(TAG, "unknown mime type for " + rawUrl); break; + } + } + String encoding = mimeType.startsWith("text/")? "UTF-8" : null; + InputStream targetStream = new ByteArrayInputStream(blob); + res = new WebResourceResponse(mimeType, encoding, targetStream); + } + } catch (Exception e) { + e.printStackTrace(); + InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes()); + res = new WebResourceResponse("text/plain", "UTF-8", targetStream); + } + + if (!internetAccess) { + Map headers = new HashMap<>(); + headers.put("Content-Security-Policy", + "default-src 'self'; " + + "style-src 'self' 'unsafe-inline' blob: ; " + + "font-src 'self' data: blob: ; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ; " + + "connect-src 'self' data: blob: ; " + + "img-src 'self' data: blob: ; " + + "media-src 'self' data: blob: ;" + + "webrtc 'block' ; " + ); + headers.put("X-DNS-Prefetch-Control", "off"); + res.setResponseHeaders(headers); + } + return res; + } + + private void callJavaScriptFunction(String func) { + webView.evaluateJavascript("document.getElementById('frame').contentWindow." + func + ";", null); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + if ((eventId == DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE && event.getData1Int() == dcAppMsg.getId())) { + Log.i(TAG, "handling status update event"); + callJavaScriptFunction("__webxdcUpdate()"); + } else if ((eventId == DcContext.DC_EVENT_WEBXDC_REALTIME_DATA && event.getData1Int() == dcAppMsg.getId())) { + Log.i(TAG, "handling realtime data event"); + StringBuilder data = new StringBuilder(); + for (byte b : event.getData2Blob()) { + data.append(((int) b) + ","); + } + callJavaScriptFunction("__webxdcRealtimeData([" + data + "])"); + } else if ((eventId == DcContext.DC_EVENT_MSGS_CHANGED && event.getData2Int() == dcAppMsg.getId())) { + this.dcAppMsg = this.dcContext.getMsg(event.getData2Int()); // msg changed, reload data from db + Util.runOnAnyBackgroundThread(() -> { + final JSONObject info = dcAppMsg.getWebxdcInfo(); + final DcChat chat = dcContext.getChat(dcAppMsg.getChatId()); + Util.runOnMain(() -> { + updateTitleAndMenu(info, chat); + }); + }); + } + } + + private void updateTitleAndMenu(JSONObject info, DcChat chat) { + final String docName = JsonUtils.optString(info, "document"); + final String xdcName = JsonUtils.optString(info, "name"); + final String currSourceCodeUrl = JsonUtils.optString(info, "source_code_url"); + getSupportActionBar().setTitle((docName.isEmpty() ? xdcName : docName) + " – " + chat.getName()); + if (!sourceCodeUrl.equals(currSourceCodeUrl)) { + sourceCodeUrl = currSourceCodeUrl; + invalidateOptionsMenu(); + } + } + + private void showInChat() { + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, dcAppMsg.getChatId()); + intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, DcMsg.getMessagePosition(dcAppMsg, dcContext)); + startActivity(intent); + } + + public static void addToHomeScreen(Activity activity, int msgId) { + Context context = activity.getApplicationContext(); + try { + DcContext dcContext = DcHelper.getContext(context); + DcMsg msg = dcContext.getMsg(msgId); + final JSONObject info = msg.getWebxdcInfo(); + + final String docName = JsonUtils.optString(info, "document"); + final String xdcName = JsonUtils.optString(info, "name"); + byte[] blob = msg.getWebxdcBlob(JsonUtils.optString(info, "icon")); + ByteArrayInputStream is = new ByteArrayInputStream(blob); + BitmapDrawable drawable = (BitmapDrawable) Drawable.createFromStream(is, "icon"); + Bitmap bitmap = drawable.getBitmap(); + + ShortcutInfoCompat shortcutInfoCompat = new ShortcutInfoCompat.Builder(context, "xdc-" + dcContext.getAccountId() + "-" + msgId) + .setShortLabel(docName.isEmpty() ? xdcName : docName) + .setIcon(IconCompat.createWithBitmap(bitmap)) // createWithAdaptiveBitmap() removes decorations but cuts out a too small circle and defamiliarize the icon too much + .setIntents(getWebxdcIntentWithParentStack(context, msgId)) + .build(); + + Toast.makeText(context, R.string.one_moment, Toast.LENGTH_SHORT).show(); + if (!ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) { + Toast.makeText(context, "ErrAddToHomescreen: requestPinShortcut() failed", Toast.LENGTH_LONG).show(); + } + } catch(Exception e) { + Toast.makeText(context, "ErrAddToHomescreen: " + e, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onActivityResult(int reqCode, int resultCode, final Intent data) { + if (reqCode == REQUEST_CODE_FILE_PICKER && filePathCallback != null) { + Uri[] dataUris = null; + if (resultCode == Activity.RESULT_OK && data != null) { + try { + if (data.getDataString() != null) { + dataUris = new Uri[]{Uri.parse(data.getDataString())}; + } else if (data.getClipData() != null) { + final int numSelectedFiles = data.getClipData().getItemCount(); + dataUris = new Uri[numSelectedFiles]; + for (int i = 0; i < numSelectedFiles; i++) { + dataUris[i] = data.getClipData().getItemAt(i).getUri(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + filePathCallback.onReceiveValue(dataUris); + filePathCallback = null; + } + super.onActivityResult(reqCode, resultCode, data); + } + + private void leaveRealtimeChannel() { + int accountId = dcContext.getAccountId(); + int msgId = dcAppMsg.getId(); + try { + rpc.leaveWebxdcRealtime(accountId, msgId); + } catch (RpcException e) { + e.printStackTrace(); + } + } + + class InternalJSApi { + @JavascriptInterface + public String arcanechat() { + return BuildConfig.VERSION_NAME; + } + + @JavascriptInterface + public int sendUpdateMaxSize() { + return WebxdcActivity.this.sendUpdateMaxSize; + } + + @JavascriptInterface + public int sendUpdateInterval() { + return WebxdcActivity.this.sendUpdateInterval; + } + + @JavascriptInterface + public String selfAddr() { + return WebxdcActivity.this.selfAddr; + } + + /** @noinspection unused*/ + @JavascriptInterface + public String selfName() { + return WebxdcActivity.this.dcContext.getName(); + } + + /** @noinspection unused*/ + @JavascriptInterface + public boolean sendStatusUpdate(String payload) { + Log.i(TAG, "sendStatusUpdate"); + if (!WebxdcActivity.this.dcContext.sendWebxdcStatusUpdate(WebxdcActivity.this.dcAppMsg.getId(), payload)) { + DcChat dcChat = WebxdcActivity.this.dcContext.getChat(WebxdcActivity.this.dcAppMsg.getChatId()); + Toast.makeText(WebxdcActivity.this, + dcChat.isContactRequest() ? + WebxdcActivity.this.getString(R.string.accept_request_first) : + WebxdcActivity.this.dcContext.getLastError(), + Toast.LENGTH_LONG).show(); + return false; + } + return true; + } + + /** @noinspection unused*/ + @JavascriptInterface + public String getStatusUpdates(int lastKnownSerial) { + Log.i(TAG, "getStatusUpdates"); + return WebxdcActivity.this.dcContext.getWebxdcStatusUpdates(WebxdcActivity.this.dcAppMsg.getId(), lastKnownSerial ); + } + + /** @noinspection unused*/ + @JavascriptInterface + public String sendToChat(String message) { + Log.i(TAG, "sendToChat"); + try { + JSONObject jsonObject = new JSONObject(message); + + String text = null; + String subject = null; + String html = null; + byte[] data = null; + String name = null; + String type = null; + if (jsonObject.has("base64")) { + data = Base64.decode(jsonObject.getString("base64"), Base64.NO_WRAP | Base64.NO_PADDING); + name = jsonObject.getString("name"); + } + if (jsonObject.has("type")) { + type = jsonObject.getString("type"); + } + if (jsonObject.has("text")) { + text = jsonObject.getString("text"); + } + if (jsonObject.has("subject")) { + subject = jsonObject.getString("subject"); + } + if (jsonObject.has("html")) { + html = jsonObject.getString("html"); + } + + if (TextUtils.isEmpty(text) && TextUtils.isEmpty(subject) && TextUtils.isEmpty(html) && TextUtils.isEmpty(name)) { + return "provided file is invalid, you need to set both name and base64 content"; + } + + DcHelper.sendToChat(WebxdcActivity.this, data, type, name, html, subject, text); + return null; + } catch (Exception e) { + e.printStackTrace(); + return e.toString(); + } + } + + /** @noinspection unused*/ + @JavascriptInterface + public void sendRealtimeAdvertisement() { + int accountId = WebxdcActivity.this.dcContext.getAccountId(); + int msgId = WebxdcActivity.this.dcAppMsg.getId(); + try { + WebxdcActivity.this.rpc.sendWebxdcRealtimeAdvertisement(accountId, msgId); + } catch (RpcException e) { + e.printStackTrace(); + } + } + + /** @noinspection unused*/ + @JavascriptInterface + public void leaveRealtimeChannel() { + WebxdcActivity.this.leaveRealtimeChannel(); + } + + /** @noinspection unused*/ + @JavascriptInterface + public void sendRealtimeData(String jsonData) { + int accountId = WebxdcActivity.this.dcContext.getAccountId(); + int msgId = WebxdcActivity.this.dcAppMsg.getId(); + try { + Integer[] data = JsonUtils.fromJson(jsonData, Integer[].class); + WebxdcActivity.this.rpc.sendWebxdcRealtimeData(accountId, msgId, Arrays.asList(data)); + } catch (IOException | RpcException e) { + e.printStackTrace(); + } + } + + @JavascriptInterface + public void ttsSpeak(String text, String lang) { + if (lang != null && !lang.isEmpty()) tts.setLanguage(Locale.forLanguageTag(lang)); + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null); + } + + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/WebxdcStoreActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcStoreActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..8d1f72734944a7ea62c500e613d5ef9fd7e094af --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcStoreActivity.java @@ -0,0 +1,148 @@ +package org.thoughtcrime.securesms; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.MenuItem; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Toast; + +import androidx.appcompat.app.ActionBar; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.HttpResponse; + +public class WebxdcStoreActivity extends PassphraseRequiredActionBarActivity { + private static final String TAG = WebxdcStoreActivity.class.getSimpleName(); + + private DcContext dcContext; + private Rpc rpc; + + @Override + protected void onCreate(Bundle state, boolean ready) { + setContentView(R.layout.web_view_activity); + rpc = DcHelper.getRpc(this); + dcContext = DcHelper.getContext(this); + WebView webView = findViewById(R.id.webview); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.content_container)); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.webxdc_apps); + } + + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + String ext = MediaUtil.getFileExtensionFromUrl(Uri.parse(url).getPath()); + if ("xdc".equals(ext)) { + Util.runOnAnyBackgroundThread(() -> { + try { + HttpResponse httpResponse = rpc.getHttpResponse(dcContext.getAccountId(), url); + byte[] blob = JsonUtils.decodeBase64(httpResponse.blob); + Uri uri = PersistentBlobProvider.getInstance().create(WebxdcStoreActivity.this, blob, "application/octet-stream", "app.xdc"); + Intent intent = new Intent(); + intent.setData(uri); + setResult(Activity.RESULT_OK, intent); + finish(); + } catch (RpcException e) { + e.printStackTrace(); + Util.runOnMain(() -> Toast.makeText(WebxdcStoreActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show()); + } + }); + } else { + IntentUtils.showInBrowser(WebxdcStoreActivity.this, url); + } + return true; + } + + @TargetApi(Build.VERSION_CODES.N) + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return shouldOverrideUrlLoading(view, request.getUrl().toString()); + } + + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + return interceptRequest(request.getUrl().toString()); + } + }); + + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setAllowFileAccess(false); + webSettings.setAllowContentAccess(false); + webSettings.setGeolocationEnabled(false); + webSettings.setAllowFileAccessFromFileURLs(false); + webSettings.setAllowUniversalAccessFromFileURLs(false); + webSettings.setDatabaseEnabled(true); + webSettings.setDomStorageEnabled(true); + webView.setNetworkAvailable(true); // this does not block network but sets `window.navigator.isOnline` in js land + + webView.loadUrl(Prefs.getWebxdcStoreUrl(this)); + } + + private WebResourceResponse interceptRequest(String url) { + WebResourceResponse res = null; + try { + if (url == null) { + throw new Exception("no url specified"); + } + HttpResponse httpResponse = rpc.getHttpResponse(dcContext.getAccountId(), url); + String mimeType = httpResponse.mimetype; + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + + byte[] blob = JsonUtils.decodeBase64(httpResponse.blob); + ByteArrayInputStream data = new ByteArrayInputStream(blob); + res = new WebResourceResponse(mimeType, httpResponse.encoding, data); + } catch (Exception e) { + e.printStackTrace(); + ByteArrayInputStream data = new ByteArrayInputStream(("Could not load apps. Are you online?\n\n" + e.getMessage()).getBytes()); + res = new WebResourceResponse("text/plain", "UTF-8", data); + } + + HashMap headers = new HashMap<>(); + headers.put("access-control-allow-origin", "*"); + res.setResponseHeaders(headers); + return res; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + return false; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/WelcomeActivity.java b/src/main/java/org/thoughtcrime/securesms/WelcomeActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..55467d9569c28523d512ca77da8bd4b92103a260 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/WelcomeActivity.java @@ -0,0 +1,390 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.util.Linkify; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcLot; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.qr.BackupTransferActivity; +import org.thoughtcrime.securesms.qr.RegistrationQrActivity; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.StreamUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class WelcomeActivity extends BaseActionBarActivity implements DcEventCenter.DcEventDelegate { + public static final String BACKUP_QR_EXTRA = "backup_qr_extra"; + public static final int PICK_BACKUP = 20574; + private final static String TAG = WelcomeActivity.class.getSimpleName(); + public static final String TMP_BACKUP_FILE = "tmp-backup-file"; + + private ProgressDialog progressDialog = null; + private boolean imexUserAborted; + DcContext dcContext; + private NotificationController notificationController; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.welcome_activity); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.content_container)); + + findViewById(R.id.signup_button).setOnClickListener((v) -> startInstantOnboardingActivity()); + findViewById(R.id.add_as_second_device_button).setOnClickListener((v) -> startAddAsSecondDeviceActivity()); + findViewById(R.id.backup_button).setOnClickListener((v) -> startImportBackup()); + + registerForEvents(); + initializeActionBar(); + + DcHelper.maybeShowMigrationError(this); + } + + protected void initializeActionBar() { + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar == null) throw new AssertionError(); + + boolean canGoBack = AccountManager.getInstance().canRollbackAccountCreation(this); + supportActionBar.setDisplayHomeAsUpEnabled(canGoBack); + getSupportActionBar().setTitle(canGoBack? R.string.add_account : R.string.app_name); + } + + private void registerForEvents() { + dcContext = DcHelper.getContext(this); + DcHelper.getEventCenter(this).addObserver(DcContext.DC_EVENT_IMEX_PROGRESS, this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + } + + return false; + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + public void onStart() { + super.onStart(); + String backupQr = getIntent().getStringExtra(BACKUP_QR_EXTRA); + if (backupQr != null) { + getIntent().removeExtra(BACKUP_QR_EXTRA); + startBackupTransfer(backupQr); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void startInstantOnboardingActivity() { + Intent intent = new Intent(this, InstantOnboardingActivity.class); + intent.putExtra(InstantOnboardingActivity.FROM_WELCOME, true); + startActivity(intent); + } + + private void startAddAsSecondDeviceActivity() { + new IntentIntegrator(this).setCaptureActivity(RegistrationQrActivity.class) + .addExtra(RegistrationQrActivity.ADD_AS_SECOND_DEVICE_EXTRA, true) + .initiateScan(); + } + + @SuppressLint("InlinedApi") + private void startImportBackup() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .alwaysGrantOnSdk30() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> { + File imexDir = DcHelper.getImexDir(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + AttachmentManager.selectMediaType(this, "application/x-tar", null, PICK_BACKUP, StorageUtil.getDownloadUri()); + } else { + final String backupFile = dcContext.imexHasBackup(imexDir.getAbsolutePath()); + if (backupFile != null) { + new AlertDialog.Builder(this) + .setTitle(R.string.import_backup_title) + .setMessage(String.format(getResources().getString(R.string.import_backup_ask), backupFile)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> startImport(backupFile, null)) + .show(); + } + else { + new AlertDialog.Builder(this) + .setTitle(R.string.import_backup_title) + .setMessage(String.format(getResources().getString(R.string.import_backup_no_backup_found), imexDir.getAbsolutePath())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + } + }) + .execute(); + } + + private void startImport(@Nullable final String backupFile, final @Nullable Uri backupFileUri) + { + notificationController = GenericForegroundService.startForegroundTask(this, getString(R.string.import_backup_title)); + + if( progressDialog!=null ) { + progressDialog.dismiss(); + progressDialog = null; + } + + imexUserAborted = false; + progressDialog = new ProgressDialog(this); + progressDialog.setMessage(getResources().getString(R.string.one_moment)); + progressDialog.setCanceledOnTouchOutside(false); + progressDialog.setCancelable(false); + progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getResources().getString(android.R.string.cancel), (dialog, which) -> { + imexUserAborted = true; + dcContext.stopOngoingProcess(); + notificationController.close(); + cleanupTempBackupFile(); + }); + progressDialog.show(); + + Util.runOnBackground(() -> { + String file = backupFile; + if (backupFile == null) { + try { + file = copyToCacheDir(backupFileUri).getAbsolutePath(); + } catch (IOException e) { + e.printStackTrace(); + notificationController.close(); + cleanupTempBackupFile(); + return; + } + } + + DcHelper.getEventCenter(this).captureNextError(); + dcContext.imex(DcContext.DC_IMEX_IMPORT_BACKUP, file); + }); + } + + private File copyToCacheDir(Uri uri) throws IOException { + try (InputStream inputStream = PartAuthority.getAttachmentStream(this, uri)) { + File file = File.createTempFile(TMP_BACKUP_FILE, ".tmp", getCacheDir()); + try (OutputStream outputStream = new FileOutputStream(file)) { + StreamUtil.copy(inputStream, outputStream); + } + return file; + } + } + + private void startBackupTransfer(String qrCode) + { + if (progressDialog!=null) { + progressDialog.dismiss(); + progressDialog = null; + } + + Intent intent = new Intent(this, BackupTransferActivity.class); + intent.putExtra(BackupTransferActivity.TRANSFER_MODE, BackupTransferActivity.TransferMode.RECEIVER_SCAN_QR.getInt()); + intent.putExtra(BackupTransferActivity.QR_CODE, qrCode); + startActivity(intent); + } + + private void progressError(String data2) { + progressDialog.dismiss(); + maybeShowConfigurationError(this, data2); + } + + private void progressUpdate(int progress) { + int percent = progress / 10; + progressDialog.setMessage(getResources().getString(R.string.one_moment)+String.format(" %d%%", percent)); + } + + private void progressSuccess() { + DcHelper.getEventCenter(this).endCaptureNextError(); + progressDialog.dismiss(); + Intent intent = new Intent(getApplicationContext(), ConversationListActivity.class); + intent.putExtra(ConversationListActivity.FROM_WELCOME, true); + startActivity(intent); + finish(); + } + + public static void maybeShowConfigurationError(Activity activity, String data2) { + if (activity.isFinishing()) return; // avoid android.view.WindowManager$BadTokenException + + if (data2 != null && !data2.isEmpty()) { + AlertDialog d = new AlertDialog.Builder(activity) + .setMessage(data2) + .setPositiveButton(android.R.string.ok, null) + .create(); + d.show(); + try { + //noinspection ConstantConditions + Linkify.addLinks((TextView) d.findViewById(android.R.id.message), Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES); + } catch(NullPointerException e) { + e.printStackTrace(); + } + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + + if (eventId== DcContext.DC_EVENT_IMEX_PROGRESS ) { + long progress = event.getData1Int(); + if (progressDialog == null || notificationController == null) { + // IMEX runs in BackupTransferActivity + if (progress == 1000) { + finish(); // transfer done - remove ourself from the activity stack (finishAffinity is available in API 16, we're targeting API 14) + } + return; + } + if (progress==0/*error/aborted*/) { + if (!imexUserAborted) { + progressError(dcContext.getLastError()); + } + notificationController.close(); + cleanupTempBackupFile(); + } + else if (progress<1000/*progress in permille*/) { + progressUpdate((int)progress); + notificationController.setProgress(1000, progress, String.format(" %d%%", (int) progress / 10)); + } + else if (progress==1000/*done*/) { + DcHelper.getAccounts(this).startIo(); + progressSuccess(); + notificationController.close(); + cleanupTempBackupFile(); + } + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode != RESULT_OK) { + return; + } + + if (requestCode==IntentIntegrator.REQUEST_CODE) { + String qrRaw = data.getStringExtra(RegistrationQrActivity.QRDATA_EXTRA); + if (qrRaw == null) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + if (scanResult == null || scanResult.getFormatName() == null) { + return; // aborted + } + qrRaw = scanResult.getContents(); + } + DcLot qrParsed = dcContext.checkQr(qrRaw); + switch (qrParsed.getState()) { + case DcContext.DC_QR_BACKUP2: + final String finalQrRaw = qrRaw; + new AlertDialog.Builder(this) + .setTitle(R.string.multidevice_receiver_title) + .setMessage(R.string.multidevice_receiver_scanning_ask) + .setPositiveButton(R.string.perm_continue, (dialog, which) -> startBackupTransfer(finalQrRaw)) + .setNegativeButton(R.string.cancel, null) + .setCancelable(false) + .show(); + break; + + case DcContext.DC_QR_BACKUP_TOO_NEW: + new AlertDialog.Builder(this) + .setTitle(R.string.multidevice_receiver_title) + .setMessage(R.string.multidevice_receiver_needs_update) + .setPositiveButton(R.string.ok, null) + .show(); + break; + + default: + new AlertDialog.Builder(this) + .setMessage(R.string.qraccount_qr_code_cannot_be_used) + .setPositiveButton(R.string.ok, null) + .show(); + break; + } + } else if (requestCode == PICK_BACKUP) { + Uri uri = (data != null ? data.getData() : null); + if (uri == null) { + Log.e(TAG, " Can't import null URI"); + return; + } + startImport(null, uri); + } + } + + private void cleanupTempBackupFile() { + try { + File[] files = getCacheDir().listFiles((dir, name) -> name.startsWith(TMP_BACKUP_FILE)); + for (File file : files) { + if (file.getName().endsWith("tmp")) { + Log.i(TAG, "Deleting temp backup file " + file); + file.delete(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void onBackPressed() { + AccountManager accountManager = AccountManager.getInstance(); + if (accountManager.canRollbackAccountCreation(this)) { + accountManager.rollbackAccountCreation(this); + } else { + super.onBackPressed(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListAdapter.java b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..8a990f4086abc4b4a3601accb05e1d225acab5c6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListAdapter.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.accounts; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; + +public class AccountSelectionListAdapter extends RecyclerView.Adapter +{ + private final @NonNull AccountSelectionListFragment fragment; + private final @NonNull DcAccounts accounts; + private @NonNull int[] accountList = new int[0]; + private int selectedAccountId; + private final LayoutInflater li; + private final ItemClickListener clickListener; + private final GlideRequests glideRequests; + + @Override + public int getItemCount() { + return accountList.length; + } + + public static class AccountViewHolder extends RecyclerView.ViewHolder { + AccountViewHolder(@NonNull final View itemView, + @Nullable final ItemClickListener clickListener) { + super(itemView); + itemView.setOnClickListener(view -> { + if (clickListener != null) { + clickListener.onItemClick(getView()); + } + }); + } + + public AccountSelectionListItem getView() { + return (AccountSelectionListItem) itemView; + } + + public void bind(@NonNull GlideRequests glideRequests, int accountId, DcContext dcContext, boolean selected, AccountSelectionListFragment fragment) { + getView().bind(glideRequests, accountId, dcContext, selected, fragment); + } + + public void unbind(@NonNull GlideRequests glideRequests) { + getView().unbind(glideRequests); + } + } + + public AccountSelectionListAdapter(@NonNull AccountSelectionListFragment fragment, + @NonNull GlideRequests glideRequests, + @Nullable ItemClickListener clickListener) + { + super(); + Context context = fragment.requireActivity(); + this.fragment = fragment; + this.accounts = DcHelper.getAccounts(context); + this.li = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.clickListener = clickListener; + } + + @NonNull + @Override + public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new AccountViewHolder(li.inflate(R.layout.account_selection_list_item, parent, false), clickListener); + } + + @Override + public void onBindViewHolder(@NonNull AccountViewHolder holder, int i) { + int id = accountList[i]; + DcContext dcContext = accounts.getAccount(id); + + holder.unbind(glideRequests); + holder.bind(glideRequests, id, dcContext, id == selectedAccountId, fragment); + } + + public interface ItemClickListener { + void onItemClick(AccountSelectionListItem item); + } + + public void changeData(int[] ids, int selectedAccountId) { + this.accountList = ids==null? new int[0] : ids; + this.selectedAccountId = selectedAccountId; + notifyDataSetChanged(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..01f64f489334cf0a10456285cc0a5a399b94fd3d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms.accounts; + +import static com.b44t.messenger.DcContact.DC_CONTACT_ID_ADD_ACCOUNT; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PRIVATE_TAG; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.ConnectivityActivity; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Arrays; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class AccountSelectionListFragment extends DialogFragment implements DcEventCenter.DcEventDelegate +{ + private static final String TAG = AccountSelectionListFragment.class.getSimpleName(); + private RecyclerView recyclerView; + private AccountSelectionListAdapter adapter; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.switch_account) + .setNeutralButton(R.string.connectivity, ((dialog, which) -> { + startActivity(new Intent(getActivity(), ConnectivityActivity.class)); + })) + .setNegativeButton(R.string.cancel, null); + + LayoutInflater inflater = getActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.account_selection_list_fragment, null); + recyclerView = ViewUtil.findById(view, R.id.recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + + adapter = new AccountSelectionListAdapter(this, GlideApp.with(getActivity()), new ListClickListener()); + recyclerView.setAdapter(adapter); + refreshData(); + DcEventCenter eventCenter = DcHelper.getEventCenter(requireActivity()); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_CONNECTIVITY_CHANGED, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_MSGS_NOTICED, this); + + return builder.setView(view).create(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(requireActivity()).removeObservers(this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + refreshData(); + } + + private void refreshData() { + if (adapter == null) return; + + DcAccounts accounts = DcHelper.getAccounts(getActivity()); + int[] accountIds = accounts.getAll(); + + int[] ids = new int[accountIds.length + 1]; + int j = 0; + for (int accountId : accountIds) { + ids[j++] = accountId; + } + ids[j] = DC_CONTACT_ID_ADD_ACCOUNT; + adapter.changeData(ids, accounts.getSelectedAccount().getAccountId()); + } + + @Override + public void onCreateContextMenu(@NonNull ContextMenu menu, @NonNull View v, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + requireActivity().getMenuInflater().inflate(R.menu.account_item_context, menu); + + AccountSelectionListItem listItem = (AccountSelectionListItem) v; + int accountId = listItem.getAccountId(); + DcAccounts dcAccounts = DcHelper.getAccounts(requireActivity()); + + Util.redMenuItem(menu, R.id.delete); + + if (dcAccounts.getAccount(accountId).isMuted()) { + menu.findItem(R.id.menu_mute_notifications).setTitle(R.string.menu_unmute); + } + + // hack to make onContextItemSelected() work with DialogFragment, + // see https://stackoverflow.com/questions/15929026/oncontextitemselected-does-not-get-called-in-a-dialogfragment + MenuItem.OnMenuItemClickListener listener = item -> { + onContextItemSelected(item, accountId); + return true; + }; + for (int i = 0, n = menu.size(); i < n; i++) { + menu.getItem(i).setOnMenuItemClickListener(listener); + } + // /hack + } + + private void onContextItemSelected(MenuItem item, int accountId) { + int itemId = item.getItemId(); + if (itemId == R.id.delete) { + onDeleteAccount(accountId); + } else if (itemId == R.id.menu_mute_notifications) { + onToggleMute(accountId); + } else if (itemId == R.id.menu_set_tag) { + onSetTag(accountId); + } else if (itemId == R.id.menu_move_to_top) { + onMoveToTop(accountId); + } + } + + private void onMoveToTop(int accountId) { + Activity activity = getActivity(); + if (activity == null) return; + + int[] accountIds = DcHelper.getAccounts(activity).getAll(); + Integer[] ids = new Integer[accountIds.length]; + ids[0] = accountId; + int j = 1; + for (int accId : accountIds) { + if (accId != accountId) { + ids[j++] = accId; + } + } + + Rpc rpc = DcHelper.getRpc(activity); + try { + rpc.setAccountsOrder(Arrays.asList(ids)); + } catch (RpcException e) { + Log.e(TAG, "Error calling rpc.setAccountsOrder()", e); + } + + refreshData(); + } + + private void onSetTag(int accountId) { + Activity activity = getActivity(); + if (activity == null) return; + AccountSelectionListFragment.this.dismiss(); + + DcContext dcContext = DcHelper.getAccounts(activity).getAccount(accountId); + View view = View.inflate(activity, R.layout.single_line_input, null); + EditText inputField = view.findViewById(R.id.input_field); + inputField.setHint(R.string.profile_tag_hint); + inputField.setText(dcContext.getConfig(CONFIG_PRIVATE_TAG)); + + new AlertDialog.Builder(activity) + .setTitle(R.string.profile_tag) + .setMessage(R.string.profile_tag_explain) + .setView(view) + .setPositiveButton(android.R.string.ok, (d, b) -> { + String newTag = inputField.getText().toString().trim(); + dcContext.setConfig(CONFIG_PRIVATE_TAG, newTag); + AccountManager.getInstance().showSwitchAccountMenu(activity); + }) + .setNegativeButton(R.string.cancel, (d, b) -> AccountManager.getInstance().showSwitchAccountMenu(activity)) + .show(); + } + + private void onDeleteAccount(int accountId) { + Activity activity = getActivity(); + AccountSelectionListFragment.this.dismiss(); + if (activity == null) return; + DcAccounts accounts = DcHelper.getAccounts(activity); + Rpc rpc = DcHelper.getRpc(activity); + + View dialogView = View.inflate(activity, R.layout.dialog_delete_profile, null); + AvatarView avatar = dialogView.findViewById(R.id.avatar); + TextView nameView = dialogView.findViewById(R.id.name); + TextView addrView = dialogView.findViewById(R.id.address); + TextView sizeView = dialogView.findViewById(R.id.size_label); + TextView description = dialogView.findViewById(R.id.description); + DcContext dcContext = accounts.getAccount(accountId); + String name = dcContext.getConfig("displayname"); + DcContact contact = dcContext.getContact(DcContact.DC_CONTACT_ID_SELF); + if (TextUtils.isEmpty(name)) { + name = contact.getAddr(); + } + Recipient recipient = new Recipient(requireContext(), contact, name); + avatar.setAvatar(GlideApp.with(activity), recipient, false); + nameView.setText(name); + addrView.setText(contact.getAddr()); + Util.runOnAnyBackgroundThread(() -> { + try { + final int sizeBytes = rpc.getAccountFileSize(accountId); + Util.runOnMain(() -> { + sizeView.setText(Util.getPrettyFileSize(sizeBytes)); + }); + } catch (RpcException e) { + Log.e(TAG, "Error calling rpc.getAccountFileSize()", e); + } + }); + description.setText(activity.getString(R.string.delete_account_explain_with_name, name)); + + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle(R.string.delete_account) + .setView(dialogView) + .setNegativeButton(R.string.cancel, (d, which) -> AccountManager.getInstance().showSwitchAccountMenu(activity)) + .setPositiveButton(R.string.delete, (d2, which2) -> { + boolean selected = accountId == accounts.getSelectedAccount().getAccountId(); + DcHelper.getNotificationCenter(activity).removeAllNotifications(accountId); + accounts.removeAccount(accountId); + if (selected) { + DcContext selAcc = accounts.getSelectedAccount(); + AccountManager.getInstance().switchAccountAndStartActivity(activity, selAcc.isOk()? selAcc.getAccountId() : 0); + } else { + AccountManager.getInstance().showSwitchAccountMenu(activity); + } + + // title update needed to show "Delta Chat" in case there is only one profile left + if (activity instanceof ConversationListActivity) { + ((ConversationListActivity)activity).refreshTitle(); + } + }) + .show(); + Util.redPositiveButton(dialog); + } + + private void onToggleMute(int accountId) { + DcAccounts dcAccounts = DcHelper.getAccounts(requireActivity()); + DcContext dcContext = dcAccounts.getAccount(accountId); + dcContext.setMuted(!dcContext.isMuted()); + recyclerView.getAdapter().notifyDataSetChanged(); + } + + private class ListClickListener implements AccountSelectionListAdapter.ItemClickListener { + + @Override + public void onItemClick(AccountSelectionListItem contact) { + Activity activity = requireActivity(); + AccountSelectionListFragment.this.dismiss(); + int accountId = contact.getAccountId(); + if (accountId == DC_CONTACT_ID_ADD_ACCOUNT) { + AccountManager.getInstance().switchAccountAndStartActivity(activity, 0); + } else if (accountId != DcHelper.getAccounts(activity).getSelectedAccount().getAccountId()) { + AccountManager.getInstance().switchAccountAndStartActivity(activity, accountId); + } + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListItem.java b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListItem.java new file mode 100644 index 0000000000000000000000000000000000000000..c9c232072a4c3cd9e151d96383088d3bbc07ed52 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListItem.java @@ -0,0 +1,167 @@ +package org.thoughtcrime.securesms.accounts; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_DISPLAY_NAME; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PRIVATE_TAG; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.SwitchCompat; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AccountSelectionListItem extends LinearLayout { + + private AvatarView contactPhotoImage; + private View addrContainer; + private TextView addrOrTagView; + private TextView nameView; + private ImageView unreadIndicator; + private SwitchCompat enableSwitch; + + private int accountId; + private DcContext dcContext; + + public AccountSelectionListItem(Context context) { + super(context); + } + + public AccountSelectionListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.contactPhotoImage = findViewById(R.id.contact_photo_image); + this.addrContainer = findViewById(R.id.addr_container); + this.addrOrTagView = findViewById(R.id.addr_or_tag); + this.nameView = findViewById(R.id.name); + this.unreadIndicator = findViewById(R.id.unread_indicator); + this.enableSwitch = findViewById(R.id.enable_switch); + + enableSwitch.setOnCheckedChangeListener((view, isChecked) -> { + if (isChecked != this.dcContext.isEnabled()) this.dcContext.setEnabled(isChecked); + }); + ViewUtil.setTextViewGravityStart(this.nameView, getContext()); + } + + public void bind(@NonNull GlideRequests glideRequests, int accountId, DcContext dcContext, boolean selected, AccountSelectionListFragment fragment) { + this.accountId = accountId; + this.dcContext = dcContext; + DcContact self = null; + String name; + String addrOrTag = null; + int unreadCount = 0; + boolean isMuted = dcContext.isMuted(); + + Recipient recipient; + if (accountId == DcContact.DC_CONTACT_ID_ADD_ACCOUNT) { + name = getContext().getString(R.string.add_account); + enableSwitch.setVisibility(View.INVISIBLE); + recipient = null; + this.contactPhotoImage.setSeenRecently(false); // hide connectivity dot + } else { + self = dcContext.getContact(DcContact.DC_CONTACT_ID_SELF); + name = dcContext.getConfig(CONFIG_DISPLAY_NAME); + if (TextUtils.isEmpty(name)) { + name = self.getAddr(); + } + + addrOrTag = dcContext.getConfig(CONFIG_PRIVATE_TAG); + if ("".equals(addrOrTag) && !dcContext.isChatmail()) { + addrOrTag = self.getAddr(); + } + + unreadCount = dcContext.getFreshMsgs().length; + + enableSwitch.setChecked(dcContext.isEnabled()); + enableSwitch.setVisibility(View.VISIBLE); + recipient = new Recipient(getContext(), self, name); + this.contactPhotoImage.setConnectivity(dcContext.getConnectivity()); + } + this.contactPhotoImage.setAvatar(glideRequests, recipient, false); + + nameView.setCompoundDrawablesWithIntrinsicBounds(isMuted? R.drawable.ic_volume_off_grey600_18dp : 0, 0, 0, 0); + + setSelected(selected); + if (selected) { + addrOrTagView.setTypeface(null, Typeface.BOLD); + nameView.setTypeface(null, Typeface.BOLD); + } else { + addrOrTagView.setTypeface(null, Typeface.NORMAL); + nameView.setTypeface(null, Typeface.NORMAL); + } + + updateUnreadIndicator(unreadCount, isMuted); + setText(name, addrOrTag); + + if (accountId != DcContact.DC_CONTACT_ID_ADD_ACCOUNT) { + fragment.registerForContextMenu(this); + } else { + fragment.unregisterForContextMenu(this); + } + } + + public void unbind(GlideRequests glideRequests) { + contactPhotoImage.clear(glideRequests); + } + + private void updateUnreadIndicator(int unreadCount, boolean isMuted) { + if(unreadCount == 0) { + unreadIndicator.setVisibility(View.GONE); + } else { + final int color; + if (isMuted) { + color = getResources().getColor(ThemeUtil.isDarkTheme(getContext()) ? R.color.unread_count_muted_dark : R.color.unread_count_muted); + } else { + final TypedArray attrs = getContext().obtainStyledAttributes(new int[] { + R.attr.conversation_list_item_unreadcount_color, + }); + color = attrs.getColor(0, Color.BLACK); + } + unreadIndicator.setImageDrawable(TextDrawable.builder() + .beginConfig() + .width(ViewUtil.dpToPx(getContext(), 24)) + .height(ViewUtil.dpToPx(getContext(), 24)) + .textColor(Color.WHITE) + .bold() + .endConfig() + .buildRound(String.valueOf(unreadCount), color)); + unreadIndicator.setVisibility(View.VISIBLE); + } + } + + private void setText(String name, String addrOrTag) { + this.nameView.setText(name==null? "#" : name); + + if(!TextUtils.isEmpty(addrOrTag)) { + this.addrOrTagView.setText(addrOrTag); + this.addrContainer.setVisibility(View.VISIBLE); + } else { + this.addrContainer.setVisibility(View.GONE); + } + } + + public int getAccountId() { + return accountId; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java b/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java new file mode 100644 index 0000000000000000000000000000000000000000..ceb452fbc832f346d964976939f59104ed8d6fe3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.animation; + + +import android.animation.Animator; + +import androidx.annotation.NonNull; + +public abstract class AnimationCompleteListener implements Animator.AnimatorListener { + @Override + public final void onAnimationStart(@NonNull Animator animation) {} + + @Override + public abstract void onAnimationEnd(@NonNull Animator animation); + + @Override + public final void onAnimationCancel(@NonNull Animator animation) {} + @Override + public final void onAnimationRepeat(@NonNull Animator animation) {} +} diff --git a/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java b/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java new file mode 100644 index 0000000000000000000000000000000000000000..daec88d9c458e77a4b1aefaa6659e3946792b7ce --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.attachments; + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; + +public abstract class Attachment { + + @NonNull + private final String contentType; + private int transferState; + private final long size; + + @Nullable + private final String fileName; + + @Nullable + private final String location; + + @Nullable + private final String fastPreflightId; + + private final boolean voiceNote; + private final int width; + private final int height; + + public Attachment(@NonNull String contentType, int transferState, long size, @Nullable String fileName, + @Nullable String location, @Nullable String fastPreflightId, boolean voiceNote, + int width, int height) + { + this.contentType = contentType; + this.transferState = transferState; + this.size = size; + this.fileName = fileName; + this.location = location; + this.fastPreflightId = fastPreflightId; + this.voiceNote = voiceNote; + this.width = width; + this.height = height; + } + + @Nullable + public abstract Uri getDataUri(); + + @Nullable + public abstract Uri getThumbnailUri(); + + public void setTransferState(int transferState) { + this.transferState = transferState; + } + + public int getTransferState() { + return transferState; + } + + public long getSize() { + return size; + } + + @Nullable + public String getFileName() { + return fileName; + } + + @NonNull + public String getContentType() { + return contentType; + } + + @Nullable + public String getLocation() { + return location; + } + + @Nullable + public String getFastPreflightId() { + return fastPreflightId; + } + + public boolean isVoiceNote() { + return voiceNote; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public String getRealPath(Context context) { + try { + // get file in the blobdir as `/[-].` + String filename = getFileName(); + String ext = ""; + if(filename==null) { + filename = new SimpleDateFormat("yyyy-MM-dd-HH-mm").format(new Date()); + ext = "." + MediaUtil.getExtensionFromMimeType(getContentType()); + } + else { + int i = filename.lastIndexOf("."); + if(i>=0) { + ext = filename.substring(i); + filename = filename.substring(0, i); + } + } + String path = DcHelper.getBlobdirFile(DcHelper.getContext(context), filename, ext); + + // copy content to this file + InputStream inputStream = PartAuthority.getAttachmentStream(context, getDataUri()); + OutputStream outputStream = new FileOutputStream(path); + Util.copy(inputStream, outputStream); + + return path; + } + catch(Exception e) { + e.printStackTrace(); + return null; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/attachments/DcAttachment.java b/src/main/java/org/thoughtcrime/securesms/attachments/DcAttachment.java new file mode 100644 index 0000000000000000000000000000000000000000..ce5164ad4d5fe32fe22dd0922d20d57026a4b00f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/attachments/DcAttachment.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.attachments; + +import java.io.File; + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; + +public class DcAttachment extends Attachment { + + private final DcMsg dcMsg; + + public DcAttachment(DcMsg dcMsg) { + super(dcMsg.getFilemime(), AttachmentDatabase.TRANSFER_PROGRESS_DONE, dcMsg.getFilebytes(), + dcMsg.getFilename(), + Uri.fromFile(new File(dcMsg.getFile())).toString(), + null, dcMsg.getType() == DcMsg.DC_MSG_VOICE, + 0, 0); + this.dcMsg = dcMsg; + } + + @Nullable + @Override + public Uri getDataUri() { + return Uri.fromFile(new File(dcMsg.getFile())); + } + + @Nullable + @Override + public Uri getThumbnailUri() { + if(dcMsg.getType()==DcMsg.DC_MSG_VIDEO) { + return Uri.fromFile(new File(dcMsg.getFile()+"-preview.jpg")); + } + return getDataUri(); + } + + @Override + public String getRealPath(Context context) { + return dcMsg.getFile(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java b/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java new file mode 100644 index 0000000000000000000000000000000000000000..3c941af3cedd9b01f0a744d2d33e199f2e1511d3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class UriAttachment extends Attachment { + + private final @NonNull Uri dataUri; + private final @Nullable Uri thumbnailUri; + + public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri, + @NonNull String contentType, int transferState, long size, int width, int height, + @Nullable String fileName, @Nullable String fastPreflightId, + boolean voiceNote) + { + super(contentType, transferState, size, fileName, null, fastPreflightId, voiceNote, width, height); + this.dataUri = dataUri; + this.thumbnailUri = thumbnailUri; + } + + @Override + @NonNull + public Uri getDataUri() { + return dataUri; + } + + @Override + @Nullable + public Uri getThumbnailUri() { + return thumbnailUri; + } + + @Override + public boolean equals(Object other) { + return other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri); + } + + @Override + public int hashCode() { + return dataUri.hashCode(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java new file mode 100644 index 0000000000000000000000000000000000000000..30a5c7fb3a77b5cab0bd384b826614a200d73046 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java @@ -0,0 +1,216 @@ +package org.thoughtcrime.securesms.audio; + +import android.content.Context; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaRecorder; +import android.util.Log; + +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; + +public class AudioCodec { + + private static final String TAG = AudioCodec.class.getSimpleName(); + + private static final int SAMPLE_RATE_BALANCED = 44100; + private static final int SAMPLE_RATE_INDEX_BALANCED = 4; + private static final int BIT_RATE_BALANCED = 32000; + + private static final int SAMPLE_RATE_WORSE = 24000; + private static final int SAMPLE_RATE_INDEX_WORSE = 6; + private static final int BIT_RATE_WORSE = 16000; + + private static final int CHANNELS = 1; + + private final int bufferSize; + private final MediaCodec mediaCodec; + private final AudioRecord audioRecord; + private final Context context; + + private boolean running = true; + private boolean finished = false; + + public AudioCodec(Context context) throws IOException { + this.context = context; + this.bufferSize = AudioRecord.getMinBufferSize(getSampleRate(), AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); + this.audioRecord = createAudioRecord(this.bufferSize); + this.mediaCodec = createMediaCodec(this.bufferSize); + + this.mediaCodec.start(); + + try { + audioRecord.startRecording(); + } catch (Exception e) { + Log.w(TAG, e); + mediaCodec.release(); + throw new IOException(e); + } + } + + public synchronized void stop() { + running = false; + while (!finished) Util.wait(this, 0); + } + + public void start(final OutputStream outputStream) { + new Thread(new Runnable() { + @Override + public void run() { + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + byte[] audioRecordData = new byte[bufferSize]; + ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers(); + ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers(); + + try { + while (true) { + boolean running = isRunning(); + + handleCodecInput(audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running); + handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream); + + if (!running) break; + } + } catch (IOException e) { + Log.w(TAG, e); + } finally { + mediaCodec.stop(); + audioRecord.stop(); + + mediaCodec.release(); + audioRecord.release(); + + Util.close(outputStream); + setFinished(); + } + } + }, AudioCodec.class.getSimpleName()).start(); + } + + private synchronized boolean isRunning() { + return running; + } + + private synchronized void setFinished() { + finished = true; + notifyAll(); + } + + private void handleCodecInput(AudioRecord audioRecord, byte[] audioRecordData, + MediaCodec mediaCodec, ByteBuffer[] codecInputBuffers, + boolean running) + { + int length = audioRecord.read(audioRecordData, 0, audioRecordData.length); + int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000); + + if (codecInputBufferIndex >= 0) { + ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex]; + codecBuffer.clear(); + codecBuffer.put(audioRecordData); + mediaCodec.queueInputBuffer(codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + } + + private void handleCodecOutput(MediaCodec mediaCodec, + ByteBuffer[] codecOutputBuffers, + MediaCodec.BufferInfo bufferInfo, + OutputStream outputStream) + throws IOException + { + int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + + while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { + if (codecOutputBufferIndex >= 0) { + ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex]; + + encoderOutputBuffer.position(bufferInfo.offset); + encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size); + + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) { + byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset); + + + outputStream.write(header); + + byte[] data = new byte[encoderOutputBuffer.remaining()]; + encoderOutputBuffer.get(data); + outputStream.write(data); + } + + encoderOutputBuffer.clear(); + + mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); + } else if (codecOutputBufferIndex== MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + codecOutputBuffers = mediaCodec.getOutputBuffers(); + } + + codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + } + + } + + private byte[] createAdtsHeader(int length) { + int frameLength = length + 7; + byte[] adtsHeader = new byte[7]; + + adtsHeader[0] = (byte) 0xFF; // Sync Word + adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC + adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6); + adtsHeader[2] |= (((byte) getSampleRateIndex()) << 2); + adtsHeader[2] |= (((byte) CHANNELS) >> 2); + adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03)); + adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF); + adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f); + adtsHeader[6] = (byte) 0xFC; + + return adtsHeader; + } + + private AudioRecord createAudioRecord(int bufferSize) { + return new AudioRecord(MediaRecorder.AudioSource.MIC, getSampleRate(), + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); + } + + private MediaCodec createMediaCodec(int bufferSize) throws IOException { + MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm"); + MediaFormat mediaFormat = new MediaFormat(); + + mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm"); + mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, getSampleRate()); + mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); + mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize); + mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, getBitRate()); + mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + + try { + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + } catch (Exception e) { + Log.w(TAG, e); + mediaCodec.release(); + throw new IOException(e); + } + + return mediaCodec; + } + + private int getSampleRate() { + return Prefs.isHardCompressionEnabled(context)? SAMPLE_RATE_WORSE : SAMPLE_RATE_BALANCED; + } + + private int getSampleRateIndex() { + return Prefs.isHardCompressionEnabled(context)? SAMPLE_RATE_INDEX_WORSE : SAMPLE_RATE_INDEX_BALANCED; + } + + private int getBitRate() { + return Prefs.isHardCompressionEnabled(context)? BIT_RATE_WORSE : BIT_RATE_BALANCED; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java new file mode 100644 index 0000000000000000000000000000000000000000..0bfd9decfef2cb590fcbf5bc44503e784d038cf8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.audio; + +import android.content.Context; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import android.util.Pair; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ThreadUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.concurrent.ExecutorService; + +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + +public class AudioRecorder { + + private static final String TAG = AudioRecorder.class.getSimpleName(); + + private static final ExecutorService executor = ThreadUtil.newDynamicSingleThreadedExecutor(); + + private final Context context; + private final PersistentBlobProvider blobProvider; + + private AudioCodec audioCodec; + private Uri captureUri; + + public AudioRecorder(@NonNull Context context) { + this.context = context; + this.blobProvider = PersistentBlobProvider.getInstance(); + } + + public void startRecording() { + Log.w(TAG, "startRecording()"); + + executor.execute(() -> { + Log.w(TAG, "Running startRecording() + " + Thread.currentThread().getId()); + try { + if (audioCodec != null) { + throw new AssertionError("We can only record once at a time."); + } + + ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe(); + + captureUri = blobProvider.create(context, new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), + MediaUtil.AUDIO_AAC, "voice.aac", null); + audioCodec = new AudioCodec(context); + + audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); + } catch (IOException e) { + Log.w(TAG, e); + } + }); + } + + public @NonNull ListenableFuture> stopRecording() { + Log.w(TAG, "stopRecording()"); + + final SettableFuture> future = new SettableFuture<>(); + + executor.execute(() -> { + if (audioCodec == null) { + sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!")); + return; + } + + audioCodec.stop(); + + try { + long size = MediaUtil.getMediaSize(context, captureUri); + sendToFuture(future, new Pair<>(captureUri, size)); + } catch (IOException ioe) { + Log.w(TAG, ioe); + sendToFuture(future, ioe); + } + + audioCodec = null; + captureUri = null; + }); + + return future; + } + + private void sendToFuture(final SettableFuture future, final Exception exception) { + Util.runOnMain(() -> future.setException(exception)); + } + + private void sendToFuture(final SettableFuture future, final T result) { + Util.runOnMain(() -> future.set(result)); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java new file mode 100644 index 0000000000000000000000000000000000000000..20cae692db7d445960c31a7ebcc78288ca2170ea --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -0,0 +1,356 @@ +package org.thoughtcrime.securesms.audio; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.util.Pair; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.guava.Optional; +import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; + +import java.io.IOException; +import java.lang.ref.WeakReference; + +public class AudioSlidePlayer { + + private static final String TAG = AudioSlidePlayer.class.getSimpleName(); + + private static @NonNull Optional playing = Optional.absent(); + + private final @NonNull Context context; + private final @NonNull AudioSlide slide; + private final @NonNull Handler progressEventHandler; + + private @NonNull WeakReference listener; + private @Nullable SimpleExoPlayer mediaPlayer; + private @Nullable SimpleExoPlayer durationCalculator; + + public synchronized static AudioSlidePlayer createFor(@NonNull Context context, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + if (playing.isPresent() && playing.get().getAudioSlide().equals(slide)) { + playing.get().setListener(listener); + return playing.get(); + } else { + return new AudioSlidePlayer(context, slide, listener); + } + } + + private AudioSlidePlayer(@NonNull Context context, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + this.context = context; + this.slide = slide; + this.listener = new WeakReference<>(listener); + this.progressEventHandler = new ProgressEventHandler(this); + } + + public void requestDuration() { + try { + LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).build(); + durationCalculator = new SimpleExoPlayer.Builder(context, new DefaultRenderersFactory(context)) + .setTrackSelector(new DefaultTrackSelector(context)) + .setLoadControl(loadControl) + .build(); + durationCalculator.setPlayWhenReady(false); + durationCalculator.addListener(new Player.Listener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == Player.STATE_READY) { + Util.runOnMain(() -> { + synchronized (AudioSlidePlayer.this) { + if (durationCalculator == null) return; + Log.d(TAG, "request duration " + durationCalculator.getDuration()); + getListener().onReceivedDuration(Long.valueOf(durationCalculator.getDuration()).intValue()); + durationCalculator.release(); + durationCalculator.removeListener(this); + durationCalculator = null; + } + }); + } + } + }); + durationCalculator.prepare(createMediaSource(slide.getUri())); + } catch (Exception e) { + Log.w(TAG, e); + getListener().onReceivedDuration(0); + } + } + + public void play(final double progress) throws IOException { + play(progress, false); + } + + private void play(final double progress, boolean earpiece) throws IOException { + if (this.mediaPlayer != null) { + return; + } + + if (slide.getUri() == null) { + throw new IOException("Slide has no URI!"); + } + + LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).build(); + this.mediaPlayer = new SimpleExoPlayer.Builder(context, new DefaultRenderersFactory(context)) + .setTrackSelector(new DefaultTrackSelector(context)) + .setLoadControl(loadControl) + .build(); + + mediaPlayer.prepare(createMediaSource(slide.getUri())); + mediaPlayer.setPlayWhenReady(true); + mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(earpiece ? C.AUDIO_CONTENT_TYPE_SPEECH : C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(earpiece ? C.USAGE_VOICE_COMMUNICATION : C.USAGE_MEDIA) + .build(), false); + mediaPlayer.addListener(new Player.Listener() { + + boolean started = false; + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + Log.d(TAG, "onPlayerStateChanged(" + playWhenReady + ", " + playbackState + ")"); + switch (playbackState) { + case Player.STATE_READY: + + Log.i(TAG, "onPrepared() " + mediaPlayer.getBufferedPercentage() + "% buffered"); + synchronized (AudioSlidePlayer.this) { + if (mediaPlayer == null) return; + Log.d(TAG, "DURATION: " + mediaPlayer.getDuration()); + + if (started) { + Log.d(TAG, "Already started. Ignoring."); + return; + } + + started = true; + + if (progress > 0) { + mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress)); + } + + setPlaying(AudioSlidePlayer.this); + } + + keepScreenOn(true); + notifyOnStart(); + progressEventHandler.sendEmptyMessage(0); + break; + + case Player.STATE_ENDED: + Log.i(TAG, "onComplete"); + synchronized (AudioSlidePlayer.this) { + getListener().onReceivedDuration(Long.valueOf(mediaPlayer.getDuration()).intValue()); + mediaPlayer.release(); + mediaPlayer = null; + } + + keepScreenOn(false); + notifyOnStop(); + progressEventHandler.removeMessages(0); + } + } + + @Override + public void onPlayerError(PlaybackException error) { + Log.w(TAG, "MediaPlayer Error: " + error); + + synchronized (AudioSlidePlayer.this) { + mediaPlayer.release(); + mediaPlayer = null; + } + + notifyOnStop(); + progressEventHandler.removeMessages(0); + + // Failed to play media file, maybe another app can handle it + int msgId = getAudioSlide().getDcMsgId(); + DcHelper.openForViewOrShare(context, msgId, Intent.ACTION_VIEW); + } + }); + } + + private MediaSource createMediaSource(@NonNull Uri uri) { + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(defaultDataSourceFactory); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); + + return new ProgressiveMediaSource.Factory(attachmentDataSourceFactory, extractorsFactory) + .createMediaSource(MediaItem.fromUri(uri)); + } + + public synchronized void stop() { + Log.i(TAG, "Stop called!"); + + keepScreenOn(false); + removePlaying(this); + + if (this.mediaPlayer != null) { + this.mediaPlayer.stop(); + this.mediaPlayer.release(); + } + + this.mediaPlayer = null; + } + + public static void stopAll() { + if (playing.isPresent()) { + synchronized (AudioSlidePlayer.class) { + if (playing.isPresent()) { + playing.get().stop(); + } + } + } + } + + public void setListener(@NonNull Listener listener) { + this.listener = new WeakReference<>(listener); + + if (this.mediaPlayer != null && this.mediaPlayer.getPlaybackState() == Player.STATE_READY) { + notifyOnStart(); + } + } + + public @NonNull AudioSlide getAudioSlide() { + return slide; + } + + + private Pair getProgress() { + if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { + return new Pair<>(0D, 0); + } else { + return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(), + (int) mediaPlayer.getCurrentPosition()); + } + } + + private void notifyOnStart() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStart(); + } + }); + } + + private void notifyOnStop() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStop(); + } + }); + } + + private void notifyOnProgress(final double progress, final long millis) { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onProgress(slide, progress, millis); + } + }); + } + + private @NonNull Listener getListener() { + Listener listener = this.listener.get(); + + if (listener != null) return listener; + else return new Listener() { + @Override + public void onStart() {} + @Override + public void onStop() {} + @Override + public void onProgress(AudioSlide slide, double progress, long millis) {} + @Override + public void onReceivedDuration(int millis) {} + }; + } + + public void keepScreenOn(boolean keepOn) { + if (context instanceof Activity) { + if (keepOn) { + ((Activity) context).getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + ((Activity) context).getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + + private synchronized static void setPlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() != player) { + playing.get().notifyOnStop(); + playing.get().stop(); + } + + playing = Optional.of(player); + } + + private synchronized static void removePlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() == player) { + playing = Optional.absent(); + } + } + + public interface Listener { + void onStart(); + void onStop(); + void onProgress(AudioSlide slide, double progress, long millis); + void onReceivedDuration(int millis); + } + + private static class ProgressEventHandler extends Handler { + + private final WeakReference playerReference; + + private ProgressEventHandler(@NonNull AudioSlidePlayer player) { + this.playerReference = new WeakReference<>(player); + } + + @Override + public void handleMessage(@NonNull Message msg) { + AudioSlidePlayer player = playerReference.get(); + + if (player == null || player.mediaPlayer == null || !isPlayerActive(player.mediaPlayer)) { + return; + } + + Pair progress = player.getProgress(); + player.notifyOnProgress(progress.first, progress.second); + sendEmptyMessageDelayed(0, 50); + } + + private boolean isPlayerActive(@NonNull SimpleExoPlayer player) { + return player.getPlaybackState() == Player.STATE_READY || player.getPlaybackState() == Player.STATE_BUFFERING; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..d20eb88a7d58de43b1705a5609ca5ee1df2ea601 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.calls; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.view.Menu; +import android.webkit.JavascriptInterface; +import android.webkit.PermissionRequest; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WebViewActivity; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class CallActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate { + private static final String TAG = CallActivity.class.getSimpleName(); + + public static final String EXTRA_ACCOUNT_ID = "acc_id"; + public static final String EXTRA_CHAT_ID = "chat_id"; + public static final String EXTRA_CALL_ID = "call_id"; + public static final String EXTRA_HASH = "hash"; + + private DcContext dcContext; + private Rpc rpc; + private int accId; + private int chatId; + private int callId; + private boolean ended = false; + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle state, boolean ready) { + super.onCreate(state, ready); + + Bundle bundle = getIntent().getExtras(); + assert bundle != null; + String hash = bundle.getString(EXTRA_HASH, ""); + accId = bundle.getInt(EXTRA_ACCOUNT_ID, -1); + chatId = bundle.getInt(EXTRA_CHAT_ID, 0); + callId = bundle.getInt(EXTRA_CALL_ID, 0); + rpc = DcHelper.getRpc(this); + dcContext = DcHelper.getAccounts(this).getAccount(accId); + + DcHelper.getNotificationCenter(this).removeCallNotification(accId, callId); + + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setMediaPlaybackRequiresUserGesture(false); + webView.addJavascriptInterface(new InternalJSApi(), "calls"); + + webView.setWebChromeClient(new WebChromeClient() { + @Override + public void onPermissionRequest(PermissionRequest request) { + Util.runOnMain(() -> request.grant(request.getResources())); + } + }); + + DcEventCenter eventCenter = DcHelper.getEventCenter(getApplicationContext()); + eventCenter.addObserver(DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CALL_ENDED, this); + + Util.runOnAnyBackgroundThread(() -> { + final DcChat chat = dcContext.getChat(chatId); + Util.runOnMain(() -> Objects.requireNonNull(getSupportActionBar()).setTitle(chat.getName())); + }); + + Permissions.with(this) + .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_camera_denied)) + .onAllGranted(() -> { + String url = "file:///android_asset/calls/index.html"; + webView.loadUrl(url + hash); + }).onAnyDenied(this::finish) + .execute(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + protected void onDestroy() { + DcHelper.getEventCenter(this).removeObservers(this); + if (callId != 0 && !ended) { + try { + rpc.endCall(accId, callId); + } catch (RpcException e) { + Log.e(TAG, "Error", e); + } + } + super.onDestroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + // do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed + return true; + } + + @Override + protected boolean openOnlineUrl(String url) { + finish(); + return true; + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + switch (event.getId()) { + case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED: + if (event.getData1Int() == callId) { + try { + String base64 = Base64.encodeToString(event.getData2Str().getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + String hash = "#onAnswer=" + URLEncoder.encode(base64, "UTF-8"); + webView.evaluateJavascript("window.location.hash = `"+hash+"`", null); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Error", e); + } + } + break; + case DcContext.DC_EVENT_CALL_ENDED: + if (event.getData1Int() == callId) { + ended = true; + finish(); + } + break; + } + } + + + class InternalJSApi { + @JavascriptInterface + public String getIceServers() { + try { + return rpc.iceServers(accId); + } catch (RpcException e) { + Log.e(TAG, "Error", e); + return null; + } + } + + @JavascriptInterface + public void startCall(String payload) { + try { + callId = rpc.placeOutgoingCall(accId, chatId, payload); + } catch (RpcException e) { + Log.e(TAG, "Error", e); + } + } + + @JavascriptInterface + public void acceptCall(String payload) { + try { + rpc.acceptIncomingCall(accId, callId, payload); + } catch (RpcException e) { + Log.e(TAG, "Error", e); + } + } + + @JavascriptInterface + public void endCall() { + finish(); + } + + @JavascriptInterface + public String getAvatar() { + final Context context = CallActivity.this; + final DcChat dcChat = dcContext.getChat(chatId); + if (!TextUtils.isEmpty(dcChat.getProfileImage())) { + return AvatarUtil.asDataUri(dcChat.getProfileImage()); + } else { + final Recipient recipient = new Recipient(context, dcChat); + return AvatarUtil.asDataUri(recipient.getFallbackAvatarDrawable(context, false)); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java b/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..f84e2385baef982e494796b6ea5f087aadea4669 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.calls; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.util.Base64; +import android.util.Log; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public class CallUtil { + private static final String TAG = CallUtil.class.getSimpleName(); + + public static void startCall(Activity activity, int chatId) { + Permissions.with(activity) + .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_camera_denied)) + .onAllGranted(() -> { + int accId = DcHelper.getContext(activity).getAccountId(); + startCall(activity, accId, chatId); + }) + .execute(); + } + + public static void startCall(Context context, int accId, int chatId) { + Intent intent = new Intent(context, CallActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, accId); + intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatId); + intent.putExtra(CallActivity.EXTRA_HASH, "#startCall"); + context.startActivity(intent); + } + + public static void openCall(Context context, int accId, int chatId, int callId, String payload) { + String base64 = Base64.encodeToString(payload.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + String hash = ""; + try { + hash = "#offerIncomingCall=" + URLEncoder.encode(base64, "UTF-8"); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Error", e); + } + + Intent intent = new Intent(context, CallActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, accId); + intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatId); + intent.putExtra(CallActivity.EXTRA_CALL_ID, callId); + intent.putExtra(CallActivity.EXTRA_HASH, hash); + context.startActivity(intent); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java b/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java new file mode 100644 index 0000000000000000000000000000000000000000..f2b6a3f76e09cd30c44d41d823536cdd762cb2aa --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AnimatingToggle extends FrameLayout { + + private View current; + + private final Animation inAnimation; + private final Animation outAnimation; + + public AnimatingToggle(Context context) { + this(context, null); + } + + public AnimatingToggle(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AnimatingToggle(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + this.outAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_out); + this.inAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_in); + this.outAnimation.setInterpolator(new FastOutSlowInInterpolator()); + this.inAnimation.setInterpolator(new FastOutSlowInInterpolator()); + } + + @Override + public void addView(@NonNull View child, int index, ViewGroup.LayoutParams params) { + super.addView(child, index, params); + + if (getChildCount() == 1) { + current = child; + child.setVisibility(View.VISIBLE); + } else { + child.setVisibility(View.GONE); + } + child.setClickable(false); + } + + public void display(@Nullable View view) { + if (view == current) return; + if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE); + if (view != null) ViewUtil.animateIn(view, inAnimation); + + current = view; + } + + public void displayQuick(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + if (view != null) view.setVisibility(View.VISIBLE); + + current = view; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java b/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java new file mode 100644 index 0000000000000000000000000000000000000000..a31d60f31ba607adfc329eb7c9b1c4962f82f338 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java @@ -0,0 +1,281 @@ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.app.Activity; +import android.content.Context; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; +import androidx.core.content.ContextCompat; +import android.util.Pair; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewTreeObserver; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupWindow; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AttachmentTypeSelector extends PopupWindow { + + public static final int ADD_GALLERY = 1; + public static final int ADD_DOCUMENT = 2; + public static final int ADD_CONTACT_INFO = 3; + public static final int TAKE_PHOTO = 4; + public static final int ADD_LOCATION = 5; + public static final int RECORD_VIDEO = 6; + public static final int ADD_WEBXDC = 7; + + private static final int ANIMATION_DURATION = 300; + + private final @NonNull LoaderManager loaderManager; + private final @NonNull RecentPhotoViewRail recentRail; + private final @NonNull ImageView imageButton; + private final @NonNull ImageView documentButton; + private final @NonNull ImageView contactButton; + //private final @NonNull ImageView cameraButton; + private final @NonNull ImageView videoButton; + private final @NonNull ImageView locationButton; + private final @NonNull ImageView webxdcButton; + + private @Nullable View currentAnchor; + private @Nullable AttachmentClickedListener listener; + private final int chatId; + + public AttachmentTypeSelector(@NonNull Context context, @NonNull LoaderManager loaderManager, @Nullable AttachmentClickedListener listener, int chatId) { + super(context); + + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + LinearLayout layout = (LinearLayout) inflater.inflate(R.layout.attachment_type_selector, null, true); + + this.listener = listener; + this.loaderManager = loaderManager; + this.chatId = chatId; + this.recentRail = ViewUtil.findById(layout, R.id.recent_photos); + this.imageButton = ViewUtil.findById(layout, R.id.gallery_button); + this.documentButton = ViewUtil.findById(layout, R.id.document_button); + this.contactButton = ViewUtil.findById(layout, R.id.contact_button); + //this.cameraButton = ViewUtil.findById(layout, R.id.camera_button); + this.videoButton = ViewUtil.findById(layout, R.id.record_video_button); + this.locationButton = ViewUtil.findById(layout, R.id.location_button); + this.webxdcButton = ViewUtil.findById(layout, R.id.webxdc_button); + + this.imageButton.setOnClickListener(new PropagatingClickListener(ADD_GALLERY)); + this.documentButton.setOnClickListener(new PropagatingClickListener(ADD_DOCUMENT)); + this.contactButton.setOnClickListener(new PropagatingClickListener(ADD_CONTACT_INFO)); + //this.cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO)); + this.videoButton.setOnClickListener(new PropagatingClickListener(RECORD_VIDEO)); + this.locationButton.setOnClickListener(new PropagatingClickListener(ADD_LOCATION)); + this.webxdcButton.setOnClickListener(new PropagatingClickListener(ADD_WEBXDC)); + this.recentRail.setListener(new RecentPhotoSelectedListener()); + + // disable location streaming button for now + //if (!Prefs.isLocationStreamingEnabled(context)) { + this.locationButton.setVisibility(View.GONE); + ViewUtil.findById(layout, R.id.location_button_label).setVisibility(View.GONE); + //} + + setLocationButtonImage(context); + + setContentView(layout); + setWidth(LinearLayout.LayoutParams.MATCH_PARENT); + setHeight(LinearLayout.LayoutParams.WRAP_CONTENT); + setBackgroundDrawable(new BitmapDrawable()); + setAnimationStyle(0); + setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); + setFocusable(true); + setTouchable(true); + + loaderManager.initLoader(1, null, recentRail); + } + + public void show(@NonNull Activity activity, final @NonNull View anchor) { + if (Permissions.hasAll(activity, Permissions.galleryPermissions())) { + recentRail.setVisibility(View.VISIBLE); + loaderManager.restartLoader(1, null, recentRail); + } else { + recentRail.setVisibility(View.GONE); + } + + this.currentAnchor = anchor; + setLocationButtonImage(activity); + + showAtLocation(anchor, Gravity.BOTTOM, 0, 0); + + getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this); + + animateWindowInCircular(anchor, getContentView()); + } + }); + + //animateButtonIn(cameraButton, ANIMATION_DURATION / 2); + animateButtonIn(videoButton, ANIMATION_DURATION / 2); + animateButtonIn(imageButton, ANIMATION_DURATION / 3); + animateButtonIn(contactButton, ANIMATION_DURATION / 3); + animateButtonIn(locationButton, ANIMATION_DURATION / 4); + animateButtonIn(documentButton, ANIMATION_DURATION / 4); + animateButtonIn(webxdcButton, 0); + } + + @Override + public void dismiss() { + animateWindowOutCircular(currentAnchor, getContentView()); + } + + public void setListener(@Nullable AttachmentClickedListener listener) { + this.listener = listener; + } + + private void setLocationButtonImage(Context context) { + int resId; + if (DcHelper.getContext(context).isSendingLocationsToChat(chatId)) { + resId = R.drawable.ic_location_off_white_24; + } else { + resId = R.drawable.ic_location_on_white_24dp; + } + + this.locationButton.setImageDrawable(ContextCompat.getDrawable(context, resId)); + } + + private void animateButtonIn(View button, int delay) { + AnimationSet animation = new AnimationSet(true); + Animation scale = new ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f); + + animation.addAnimation(scale); + animation.setInterpolator(new OvershootInterpolator(1)); + animation.setDuration(ANIMATION_DURATION); + animation.setStartOffset(delay); + button.startAnimation(animation); + } + + private void animateWindowInCircular(@Nullable View anchor, @NonNull View contentView) { + Pair coordinates = getClickOrigin(anchor, contentView); + Animator animator = ViewAnimationUtils.createCircularReveal(contentView, + coordinates.first, + coordinates.second, + 0, + Math.max(contentView.getWidth(), contentView.getHeight())); + animator.setDuration(ANIMATION_DURATION); + animator.start(); + } + + private void animateWindowOutCircular(@Nullable View anchor, @NonNull View contentView) { + Pair coordinates = getClickOrigin(anchor, contentView); + Animator animator = ViewAnimationUtils.createCircularReveal(getContentView(), + coordinates.first, + coordinates.second, + Math.max(getContentView().getWidth(), getContentView().getHeight()), + 0); + + animator.setDuration(ANIMATION_DURATION); + animator.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(@NonNull Animator animation) { + } + + @Override + public void onAnimationEnd(@NonNull Animator animation) { + AttachmentTypeSelector.super.dismiss(); + } + + @Override + public void onAnimationCancel(@NonNull Animator animation) { + } + + @Override + public void onAnimationRepeat(@NonNull Animator animation) { + } + }); + + animator.start(); + } + + private void animateWindowOutTranslate(@NonNull View contentView) { + Animation animation = new TranslateAnimation(0, 0, 0, contentView.getTop() + contentView.getHeight()); + animation.setDuration(ANIMATION_DURATION); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + AttachmentTypeSelector.super.dismiss(); + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + + getContentView().startAnimation(animation); + } + + private Pair getClickOrigin(@Nullable View anchor, @NonNull View contentView) { + if (anchor == null) return new Pair<>(0, 0); + + final int[] anchorCoordinates = new int[2]; + anchor.getLocationOnScreen(anchorCoordinates); + anchorCoordinates[0] += anchor.getWidth() / 2; + anchorCoordinates[1] += anchor.getHeight() / 2; + + final int[] contentCoordinates = new int[2]; + contentView.getLocationOnScreen(contentCoordinates); + + int x = anchorCoordinates[0] - contentCoordinates[0]; + int y = anchorCoordinates[1] - contentCoordinates[1]; + + return new Pair<>(x, y); + } + + private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener { + @Override + public void onItemClicked(Uri uri) { + animateWindowOutTranslate(getContentView()); + + if (listener != null) listener.onQuickAttachment(uri); + } + } + + private class PropagatingClickListener implements View.OnClickListener { + + private final int type; + + private PropagatingClickListener(int type) { + this.type = type; + } + + @Override + public void onClick(View v) { + animateWindowOutTranslate(getContentView()); + + if (listener != null) listener.onClick(type); + } + + } + + public interface AttachmentClickedListener { + public void onClick(int type); + public void onQuickAttachment(Uri uri); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java new file mode 100644 index 0000000000000000000000000000000000000000..ee73d0658d3dd90082af2236154751c29645d927 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -0,0 +1,301 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.util.DateUtils; + +import java.io.IOException; + + +public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener { + + private static final String TAG = AudioView.class.getSimpleName(); + + private final @NonNull AnimatingToggle controlToggle; + private final @NonNull ImageView playButton; + private final @NonNull ImageView pauseButton; + private final @NonNull SeekBar seekBar; + private final @NonNull TextView timestamp; + private final @NonNull TextView title; + private final @NonNull View mask; + + private @Nullable AudioSlidePlayer audioSlidePlayer; + private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + private int backwardsCounter; + + public AudioView(Context context) { + this(context, null); + } + + public AudioView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.audio_view, this); + + this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle); + this.playButton = (ImageView) findViewById(R.id.play); + this.pauseButton = (ImageView) findViewById(R.id.pause); + this.seekBar = (SeekBar) findViewById(R.id.seek); + this.timestamp = (TextView) findViewById(R.id.timestamp); + this.title = (TextView) findViewById(R.id.title); + this.mask = findViewById(R.id.interception_mask); + + this.timestamp.setText("00:00"); + + this.playButton.setOnClickListener(new PlayClickedListener()); + this.pauseButton.setOnClickListener(new PauseClickedListener()); + this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener()); + + this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon)); + this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon)); + this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); + this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); + + setTint(getContext().getResources().getColor(R.color.audio_icon)); + } + + public void setAudio(final @NonNull AudioSlide audio, int duration) + { + controlToggle.displayQuick(playButton); + seekBar.setEnabled(true); + seekBar.setProgress(0); + audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this); + timestamp.setText(DateUtils.getFormatedDuration(duration)); + + if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) { + title.setVisibility(View.GONE); + } + else { + title.setText(audio.getFileName().get()); + title.setVisibility(View.VISIBLE); + } + } + + @Override + public void setOnClickListener(OnClickListener listener) { + super.setOnClickListener(listener); + this.mask.setOnClickListener(listener); + } + + @Override + public void setOnLongClickListener(OnLongClickListener listener) { + super.setOnLongClickListener(listener); + this.mask.setOnLongClickListener(listener); + this.playButton.setOnLongClickListener(listener); + this.pauseButton.setOnLongClickListener(listener); + } + + public void togglePlay() { + if (this.playButton.getVisibility() == View.VISIBLE) { + playButton.performClick(); + } else { + pauseButton.performClick(); + } + } + + public String getDescription() { + String desc; + if (this.title.getVisibility() == View.VISIBLE) { + desc = getContext().getString(R.string.audio); + } else { + desc = getContext().getString(R.string.voice_message); + } + desc += "\n" + this.timestamp.getText(); + if (title.getVisibility() == View.VISIBLE) { + desc += "\n" + this.title.getText(); + } + return desc; + } + + public void setDuration(int duration) { + if (getProgress()==0) + this.timestamp.setText(DateUtils.getFormatedDuration(duration)); + } + + public void cleanup() { + if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + this.audioSlidePlayer.stop(); + } + } + + @Override + public void onReceivedDuration(int millis) { + this.timestamp.setText(DateUtils.getFormatedDuration(millis)); + } + + @Override + public void onStart() { + if (this.pauseButton.getVisibility() != View.VISIBLE) { + togglePlayToPause(); + } + } + + @Override + public void onStop() { + if (this.playButton.getVisibility() != View.VISIBLE) { + togglePauseToPlay(); + } + + if (seekBar.getProgress() + 5 >= seekBar.getMax()) { + backwardsCounter = 4; + onProgress(audioSlidePlayer.getAudioSlide(), 0.0, -1); + } + } + + public void disablePlayer(boolean disable) { + this.mask.setVisibility(disable? View.VISIBLE : View.GONE); + } + + @Override + public void onProgress(AudioSlide slide, double progress, long millis) { + if (!audioSlidePlayer.getAudioSlide().equals(slide)) { + return; + } + int seekProgress = (int) Math.floor(progress * this.seekBar.getMax()); + + if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { + backwardsCounter = 0; + this.seekBar.setProgress(seekProgress); + if (millis != -1) { + this.timestamp.setText(DateUtils.getFormatedDuration(millis)); + } + } else { + backwardsCounter++; + } + } + + public void setTint(int foregroundTint) { + this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); + this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); + + this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); + + this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); + } + + public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) { + seekBar.getGlobalVisibleRect(rect); + } + + private double getProgress() { + if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { + return 0; + } else { + return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax(); + } + } + + private void togglePlayToPause() { + controlToggle.displayQuick(pauseButton); + + AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.play_to_pause_animation); + pauseButton.setImageDrawable(playToPauseDrawable); + playToPauseDrawable.start(); + } + + private void togglePauseToPlay() { + controlToggle.displayQuick(playButton); + + AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.pause_to_play_animation); + playButton.setImageDrawable(pauseToPlayDrawable); + pauseToPlayDrawable.start(); + } + + private class PlayClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + try { + Log.w(TAG, "playbutton onClick"); + if (audioSlidePlayer != null) { + if (Build.VERSION.SDK_INT >= 26) { + if (audioFocusChangeListener == null) { + audioFocusChangeListener = focusChange -> { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS) { + pauseButton.performClick(); + } + }; + } + + AudioAttributes playbackAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + + AudioFocusRequest focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(playbackAttributes) + .setAcceptsDelayedFocusGain(false) + .setWillPauseWhenDucked(false) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build(); + + AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); + audioManager.requestAudioFocus(focusRequest); + } + + togglePlayToPause(); + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + private class PauseClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + Log.w(TAG, "pausebutton onClick"); + if (audioSlidePlayer != null) { + togglePauseToPlay(); + audioSlidePlayer.stop(); + } + } + } + + private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {} + + @Override + public synchronized void onStartTrackingTouch(SeekBar seekBar) { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.stop(); + } + } + + @Override + public synchronized void onStopTrackingTouch(SeekBar seekBar) { + try { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java new file mode 100644 index 0000000000000000000000000000000000000000..f21f09b770f1b2100f95d833694713c9a81755fc --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import android.util.AttributeSet; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.ProfileActivity; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public class AvatarImageView extends AppCompatImageView { + + private OnClickListener listener = null; + + public AvatarImageView(Context context) { + super(context); + setScaleType(ScaleType.CENTER_CROP); + } + + public AvatarImageView(Context context, AttributeSet attrs) { + super(context, attrs); + setScaleType(ScaleType.CENTER_CROP); + } + + @Override + public void setOnClickListener(OnClickListener listener) { + this.listener = listener; + super.setOnClickListener(listener); + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) { + if (recipient != null) { + ContactPhoto contactPhoto = recipient.getContactPhoto(getContext()); + requestManager.load(contactPhoto) + .error(recipient.getFallbackAvatarDrawable(getContext())) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(this); + if(quickContactEnabled) { + setAvatarClickHandler(recipient); + } + } else { + setImageDrawable(new GeneratedContactPhoto("+").asDrawable(getContext(), ThemeUtil.getDummyContactColor(getContext()))); + if (listener != null) super.setOnClickListener(listener); + } + } + + public void clear(@NonNull GlideRequests glideRequests) { + glideRequests.clear(this); + } + + private void setAvatarClickHandler(final Recipient recipient) { + if (!recipient.isMultiUserRecipient()) { + super.setOnClickListener(v -> { + if(recipient.getAddress().isDcContact()) { + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, recipient.getAddress().getDcContactId()); + getContext().startActivity(intent); + } + }); + } else { + super.setOnClickListener(listener); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AvatarSelector.java b/src/main/java/org/thoughtcrime/securesms/components/AvatarSelector.java new file mode 100644 index 0000000000000000000000000000000000000000..25430e22f0fa88a96de71598b8f5ec75f504a41f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AvatarSelector.java @@ -0,0 +1,171 @@ +package org.thoughtcrime.securesms.components; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AvatarSelector extends PopupWindow { + + public static final int ADD_GALLERY = 1; + public static final int TAKE_PHOTO = 5; + public static final int REMOVE_PHOTO = 8; + + private static final int ANIMATION_DURATION = 300; + + private final @NonNull LoaderManager loaderManager; + private final @NonNull RecentPhotoViewRail recentRail; + + private @Nullable AttachmentClickedListener listener; + + public AvatarSelector(@NonNull Context context, @NonNull LoaderManager loaderManager, @Nullable AttachmentClickedListener listener, boolean includeClear) { + super(context); + + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + LinearLayout layout = (LinearLayout) inflater.inflate(R.layout.avatar_selector, null, true); + + this.listener = listener; + this.loaderManager = loaderManager; + this.recentRail = ViewUtil.findById(layout, R.id.recent_photos); + ImageView imageButton = ViewUtil.findById(layout, R.id.gallery_button); + ImageView cameraButton = ViewUtil.findById(layout, R.id.camera_button); + ImageView closeButton = ViewUtil.findById(layout, R.id.close_button); + ImageView removeButton = ViewUtil.findById(layout, R.id.remove_button); + + imageButton.setOnClickListener(new PropagatingClickListener(ADD_GALLERY)); + cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO)); + closeButton.setOnClickListener(new CloseClickListener()); + removeButton.setOnClickListener(new PropagatingClickListener(REMOVE_PHOTO)); + this.recentRail.setListener(new RecentPhotoSelectedListener()); + if (!includeClear) { + removeButton.setVisibility(View.GONE); + ViewUtil.findById(layout, R.id.remove_button_label).setVisibility(View.GONE); + } + + setContentView(layout); + setWidth(LinearLayout.LayoutParams.MATCH_PARENT); + setHeight(LinearLayout.LayoutParams.WRAP_CONTENT); + setBackgroundDrawable(new BitmapDrawable(context.getResources(), (Bitmap) null)); + setAnimationStyle(0); + setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); + setFocusable(true); + setTouchable(true); + + loaderManager.initLoader(1, null, recentRail); + } + + public void show(@NonNull Activity activity, final @NonNull View anchor) { + if (Permissions.hasAll(activity, Permissions.galleryPermissions())) { + recentRail.setVisibility(View.VISIBLE); + loaderManager.restartLoader(1, null, recentRail); + } else { + recentRail.setVisibility(View.GONE); + } + + showAtLocation(anchor, Gravity.BOTTOM, 0, 0); + + getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getContentView().getViewTreeObserver().removeOnGlobalLayoutListener(this); + + animateWindowInTranslate(getContentView()); + } + }); + } + + @Override + public void dismiss() { + animateWindowOutTranslate(getContentView()); + } + + public void setListener(@Nullable AttachmentClickedListener listener) { + this.listener = listener; + } + + + private void animateWindowInTranslate(@NonNull View contentView) { + Animation animation = new TranslateAnimation(0, 0, contentView.getHeight(), 0); + animation.setDuration(ANIMATION_DURATION); + + getContentView().startAnimation(animation); + } + + private void animateWindowOutTranslate(@NonNull View contentView) { + Animation animation = new TranslateAnimation(0, 0, 0, contentView.getTop() + contentView.getHeight()); + animation.setDuration(ANIMATION_DURATION); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + AvatarSelector.super.dismiss(); + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + + getContentView().startAnimation(animation); + } + + private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener { + @Override + public void onItemClicked(Uri uri) { + animateWindowOutTranslate(getContentView()); + + if (listener != null) listener.onQuickAttachment(uri); + } + } + + private class PropagatingClickListener implements View.OnClickListener { + + private final int type; + + private PropagatingClickListener(int type) { + this.type = type; + } + + @Override + public void onClick(View v) { + animateWindowOutTranslate(getContentView()); + + if (listener != null) listener.onClick(type); + } + + } + + private class CloseClickListener implements View.OnClickListener { + @Override + public void onClick(View v) { + dismiss(); + } + } + + public interface AttachmentClickedListener { + void onClick(int type); + void onQuickAttachment(Uri uri); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AvatarView.java b/src/main/java/org/thoughtcrime/securesms/components/AvatarView.java new file mode 100644 index 0000000000000000000000000000000000000000..4ee5c516e2eebe13d7780ad1da6148a1eeec6c2b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/AvatarView.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AvatarView extends ConstraintLayout { + + private AvatarImageView avatarImage; + private ImageView seenRecentlyIndicator; + + public AvatarView(Context context) { + super(context); + init(); + } + + public AvatarView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public AvatarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + inflate(getContext(), R.layout.avatar_view, this); + + avatarImage = findViewById(R.id.avatar_image); + seenRecentlyIndicator = findViewById(R.id.status_indicator); + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) { + avatarImage.setAvatar(requestManager, recipient, quickContactEnabled); + } + + public void setImageDrawable(@Nullable Drawable drawable) { + avatarImage.setImageDrawable(drawable); + } + + public void setAvatarClickListener(OnClickListener listener) { + avatarImage.setOnClickListener(listener); + } + + public void setSeenRecently(boolean enabled) { + seenRecentlyIndicator.setVisibility(enabled? View.VISIBLE : View.GONE); + } + + public void setConnectivity(int connectivity) { + final int id; + String text = ""; + if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTED) { + id = R.color.status_dot_online; + } else if (connectivity >= DcContext.DC_CONNECTIVITY_WORKING) { + text = "⇅"; + id = R.color.status_dot_online; + } else if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTING) { + id = R.color.status_dot_connecting; + } else { + id = R.color.status_dot_offline; + } + int size = ViewUtil.dpToPx(getContext(), 24); + seenRecentlyIndicator.setImageDrawable(TextDrawable.builder() + .beginConfig() + .width(size) + .height(size) + .textColor(Color.WHITE) + .fontSize(ViewUtil.dpToPx(getContext(), 23)) + .bold() + .endConfig() + .buildRound(text, getResources().getColor(id))); + seenRecentlyIndicator.setVisibility(View.VISIBLE); + } + + public void clear(@NonNull GlideRequests glideRequests) { + avatarImage.clear(glideRequests); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java b/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java new file mode 100644 index 0000000000000000000000000000000000000000..4d134588d1105594e4c34a69175d5989560d64e7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; + +public class BorderlessImageView extends FrameLayout { + + private ThumbnailView image; + private View missingShade; + private ConversationItemFooter footer; + + public BorderlessImageView(@NonNull Context context) { + super(context); + init(); + } + + public BorderlessImageView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + inflate(getContext(), R.layout.sticker_view, this); + + this.image = findViewById(R.id.sticker_thumbnail); + this.missingShade = findViewById(R.id.sticker_missing_shade); + this.footer = findViewById(R.id.sticker_footer); + } + + @Override + public void setFocusable(boolean focusable) { + image.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + image.setClickable(clickable); + } + + @Override + public void setOnLongClickListener(@Nullable OnLongClickListener l) { + image.setOnLongClickListener(l); + } + + public void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { + boolean showControls = slide.asAttachment().getDataUri() == null; + + if (slide.hasSticker()) { + image.setScaleType(ImageView.ScaleType.FIT_CENTER); + image.setImageResource(glideRequests, slide); + } else { + image.setScaleType(ImageView.ScaleType.CENTER_CROP); + image.setImageResource(glideRequests, slide, slide.asAttachment().getWidth(), slide.asAttachment().getHeight()); + } + + missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE); + } + + public String getDescription() { + return getContext().getString(R.string.sticker) + "\n" + footer.getDescription(); + } + + public ConversationItemFooter getFooter() { + return footer; + } + + public void setThumbnailClickListener(@NonNull SlideClickListener listener) { + image.setThumbnailClickListener(listener); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java b/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java new file mode 100644 index 0000000000000000000000000000000000000000..a66f547907a5b58c2be4d55c4f124b5fcf133278 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +import chat.delta.rpc.types.CallInfo; +import chat.delta.rpc.types.CallState; + +public class CallItemView extends FrameLayout { + private static final String TAG = CallItemView.class.getSimpleName(); + + private final @NonNull ImageView icon; + private final @NonNull TextView title; + private final @NonNull ConversationItemFooter footer; + private CallInfo callInfo; + private CallClickListener viewListener; + + public CallItemView(Context context) { + this(context, null); + } + + public CallItemView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CallItemView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + inflate(context, R.layout.call_item_view, this); + + this.icon = findViewById(R.id.call_icon); + this.title = findViewById(R.id.title); + this.footer = findViewById(R.id.footer); + + setOnClickListener(v -> { + if (viewListener != null && callInfo != null) { + viewListener.onClick(v, callInfo); + } + }); + } + + public void setCallClickListener(CallClickListener listener) { + viewListener = listener; + } + + public void setCallItem(boolean isOutgoing, CallInfo callInfo) { + this.callInfo = callInfo; + if (callInfo.state instanceof CallState.Completed) { + footer.setCallDuration(((CallState.Completed) callInfo.state).duration); + } else { + footer.setCallDuration(0); // reset + } + + if (callInfo.state instanceof CallState.Missed) { + title.setText(R.string.missed_call); + } else if (callInfo.state instanceof CallState.Canceled) { + title.setText(R.string.canceled_call); + } else if (callInfo.state instanceof CallState.Declined) { + title.setText(R.string.declined_call); + } else { + title.setText(isOutgoing? R.string.outgoing_call : R.string.incoming_call); + } + + int[] attrs; + if (isOutgoing) { + attrs = new int[]{ + R.attr.conversation_item_outgoing_text_primary_color, + R.attr.conversation_item_outgoing_text_secondary_color, + }; + } else { + attrs = new int[]{ + R.attr.conversation_item_incoming_text_primary_color, + R.attr.conversation_item_incoming_text_secondary_color, + }; + } + try (TypedArray ta = getContext().obtainStyledAttributes(attrs)) { + icon.setColorFilter(ta.getColor(0, Color.BLACK)); + footer.setTextColor(ta.getColor(1, Color.BLACK)); + } + } + + public ConversationItemFooter getFooter() { + return footer; + } + + public String getDescription() { + return title.getText() + "\n" + footer.getDescription(); + } + + public interface CallClickListener { + void onClick(View v, CallInfo callInfo); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java b/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java new file mode 100644 index 0000000000000000000000000000000000000000..880e2cd924d501a58b47cb5dcc06f44791b0ab3d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import androidx.appcompat.widget.AppCompatImageView; +import android.util.AttributeSet; + +import org.thoughtcrime.securesms.R; + +public class CircleColorImageView extends AppCompatImageView { + + public CircleColorImageView(Context context) { + this(context, null); + } + + public CircleColorImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircleColorImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + int circleColor = Color.WHITE; + + if (attrs != null) { + try (TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CircleColorImageView, 0, 0)) { + circleColor = typedArray.getColor(R.styleable.CircleColorImageView_circleColor, Color.WHITE); + } + } + + Drawable circle = context.getResources().getDrawable(R.drawable.circle_tintable); + circle.setColorFilter(circleColor, PorterDuff.Mode.SRC_IN); + + setBackgroundDrawable(circle); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java new file mode 100644 index 0000000000000000000000000000000000000000..ad6db3ff2f6b5b6d672a258e00b3a808c8874ed0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.components; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.core.view.ContentInfoCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import androidx.core.os.BuildCompat; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.text.style.RelativeSizeSpan; +import android.util.AttributeSet; +import android.util.Log; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.util.Prefs; + +public class ComposeText extends AppCompatEditText { + + private CharSequence hint; + private SpannableString subHint; + + @Nullable private InputPanel.MediaListener mediaListener; + + public ComposeText(Context context) { + super(context); + initialize(); + } + + public ComposeText(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + @Override + public boolean onTextContextMenuItem(int id) { + if (id == android.R.id.paste) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + id = android.R.id.pasteAsPlainText; + } else if (ViewCompat.getOnReceiveContentMimeTypes(this) != null) { + // older device, manually paste as plain text + ClipboardManager cm = (ClipboardManager) getContext().getSystemService( + Context.CLIPBOARD_SERVICE); + ClipData clip = (cm == null) ? null : cm.getPrimaryClip(); + if (clip != null && clip.getItemCount() > 0) { + ContentInfoCompat payload = new ContentInfoCompat.Builder(clip, ContentInfoCompat.SOURCE_CLIPBOARD) + .setFlags(ContentInfoCompat.FLAG_CONVERT_TO_PLAIN_TEXT) + .build(); + ViewCompat.performReceiveContent(this, payload); + } + return true; + } + } + return super.onTextContextMenuItem(id); + } + + public String getTextTrimmed(){ + return getText().toString().trim(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + if (!TextUtils.isEmpty(hint)) { + if (!TextUtils.isEmpty(subHint)) { + setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint)) + .append("\n") + .append(ellipsizeToWidth(subHint))); + } else { + setHint(ellipsizeToWidth(hint)); + } + } + } + + private CharSequence ellipsizeToWidth(CharSequence text) { + return TextUtils.ellipsize(text, + getPaint(), + getWidth() - getPaddingLeft() - getPaddingRight(), + TruncateAt.END); + } + + public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { + this.hint = hint; + + if (subHint != null) { + this.subHint = new SpannableString(subHint); + this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } else { + this.subHint = null; + } + + if (this.subHint != null) { + super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint)) + .append("\n") + .append(ellipsizeToWidth(this.subHint))); + } else { + super.setHint(ellipsizeToWidth(this.hint)); + } + } + + private boolean isLandscape() { + return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public void setTransport(TransportOption transport) { + // do not add InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE + // as this removes the ability to compose multi-line messages. + + int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; + int inputType = getInputType(); + + if (isLandscape()) setImeActionLabel(getContext().getString(R.string.menu_send), EditorInfo.IME_ACTION_SEND); + else setImeActionLabel(null, 0); + + setInputType(inputType); + setImeOptions(imeOptions); + setHint(transport.getComposeHint(),null); + } + + @Override + public InputConnection onCreateInputConnection(@NonNull EditorInfo editorInfo) { + InputConnection inputConnection = super.onCreateInputConnection(editorInfo); + + if(Prefs.isEnterSendsEnabled(getContext())) { + editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + } + + if (mediaListener == null) return inputConnection; + if (inputConnection == null) return null; + + // media with mime-types defined by setContentMimeTypes() may be selected in the system keyboard + // and are passed to onCommitContent() then; + // from there we use them as stickers. + EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif", "image/webp"}); + + return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); + } + + public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) { + this.mediaListener = mediaListener; + } + + private void initialize() { + if (Prefs.isIncognitoKeyboardEnabled(getContext())) { + setImeOptions(getImeOptions() | EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING); + } + } + + private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener { + + private static final String TAG = CommitContentListener.class.getName(); + + private final InputPanel.MediaListener mediaListener; + + private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) { + this.mediaListener = mediaListener; + } + + @Override + public boolean onCommitContent(@NonNull InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { + if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + try { + inputContentInfo.requestPermission(); + } catch (Exception e) { + Log.w(TAG, e); + return false; + } + } + + if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { + mediaListener.onMediaSelected(inputContentInfo.getContentUri(), + inputContentInfo.getDescription().getMimeType(0)); + + return true; + } + + return false; + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java b/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java new file mode 100644 index 0000000000000000000000000000000000000000..651f600e5948f037007fc4c5193c4f78fed21e36 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Rect; +import androidx.appcompat.widget.Toolbar; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class ContactFilterToolbar extends Toolbar { + private OnFilterChangedListener listener; + + private final EditText searchText; + private final AnimatingToggle toggle; + private final ImageView clearToggle; + private final LinearLayout toggleContainer; + private boolean useClearButton; + + public ContactFilterToolbar(Context context) { + this(context, null); + } + + public ContactFilterToolbar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + useClearButton = true; + } + + public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.contact_filter_toolbar, this); + + this.searchText = ViewUtil.findById(this, R.id.search_view); + this.toggle = ViewUtil.findById(this, R.id.button_toggle); + this.clearToggle = ViewUtil.findById(this, R.id.search_clear); + this.toggleContainer = ViewUtil.findById(this, R.id.toggle_container); + + searchText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + + this.clearToggle.setOnClickListener(v -> { + searchText.setText(""); + displayTogglingView(null); + }); + + this.searchText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (!SearchUtil.isEmpty(searchText)) { + if(useClearButton) { + displayTogglingView(clearToggle); + } + } + else { + displayTogglingView(null); + } + notifyListener(); + } + }); + + setLogo(null); + setContentInsetStartWithNavigation(0); + + // avoid flickering by setting button_toggle to INVISIBLE in contact_filter_toolbar.xml + // and set it to VISIBLE _after_ choosing to display nothing + // (AnimatingToggle displays the first what will flash shortly otherwise) + toggle.displayQuick(null); + toggle.setVisibility(View.VISIBLE); + } + + public void clear() { + searchText.setText(""); + notifyListener(); + } + + public void setUseClearButton(boolean useClearButton) { + this.useClearButton = useClearButton; + } + + public void setOnFilterChangedListener(OnFilterChangedListener listener) { + this.listener = listener; + } + + private void notifyListener() { + if (listener != null) listener.onFilterChanged(searchText.getText().toString()); + } + + private void displayTogglingView(View view) { + toggle.display(view); + if (view!=null) { + expandTapArea(toggleContainer, view); + } + } + + private void expandTapArea(final View container, final View child) { + final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area); + + container.post(() -> { + Rect rect = new Rect(); + child.getHitRect(rect); + + rect.top -= padding; + rect.left -= padding; + rect.right += padding; + rect.bottom += padding; + + container.setTouchDelegate(new TouchDelegate(rect, child)); + }); + } + + private static class SearchUtil { + public static boolean isEmpty(EditText editText) { + return editText.getText().length() <= 0; + } + } + + public interface OnFilterChangedListener { + void onFilterChanged(String filter); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java b/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java new file mode 100644 index 0000000000000000000000000000000000000000..9b917fa135e8a580bb1ac07172b43cde9047dcdd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import org.thoughtcrime.securesms.components.viewpager.HackyViewPager; + +/** + * An implementation of {@link ViewPager} that disables swiping when the view is disabled. + */ +public class ControllableViewPager extends HackyViewPager { + + public ControllableViewPager(@NonNull Context context) { + super(context); + } + + public ControllableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + return isEnabled() && super.onTouchEvent(ev); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + return isEnabled() && super.onInterceptTouchEvent(ev); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java new file mode 100644 index 0000000000000000000000000000000000000000..ffc6906ac33010e621362e6507372ac4ecb34f9a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DateUtils; + +public class ConversationItemFooter extends LinearLayout { + + private TextView dateView; + private TextView editedView; + private ImageView bookmarkIndicatorView; + private ImageView emailIndicatorView; + private ImageView locationIndicatorView; + private DeliveryStatusView deliveryStatusView; + private Integer textColor = null; + private int callDuration = 0; + + public ConversationItemFooter(Context context) { + super(context); + init(null); + } + + public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.conversation_item_footer, this); + + dateView = findViewById(R.id.footer_date); + editedView = findViewById(R.id.footer_edited); + bookmarkIndicatorView = findViewById(R.id.footer_bookmark_indicator); + emailIndicatorView = findViewById(R.id.footer_email_indicator); + locationIndicatorView = findViewById(R.id.footer_location_indicator); + deliveryStatusView = new DeliveryStatusView(findViewById(R.id.delivery_indicator)); + + if (attrs != null) { + try (TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0)) { + setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white))); + } + } + } + + /* Call duration in seconds. Only >0 if this is a call message */ + public void setCallDuration(int duration) { + callDuration = duration; + } + + public void setMessageRecord(@NonNull DcMsg messageRecord) { + presentDate(messageRecord); + boolean bookmark = messageRecord.getOriginalMsgId() != 0 || messageRecord.getSavedMsgId() != 0; + bookmarkIndicatorView.setVisibility(bookmark ? View.VISIBLE : View.GONE); + editedView.setVisibility(messageRecord.isEdited() ? View.VISIBLE : View.GONE); + + int downloadState = messageRecord.getDownloadState(); + if (messageRecord.isSecure() || downloadState == DcMsg.DC_DOWNLOAD_AVAILABLE || downloadState == DcMsg.DC_DOWNLOAD_FAILURE || downloadState == DcMsg.DC_DOWNLOAD_IN_PROGRESS) { + emailIndicatorView.setVisibility(View.GONE); + } else { + emailIndicatorView.setVisibility(View.VISIBLE); + } + + locationIndicatorView.setVisibility(messageRecord.hasLocation() ? View.VISIBLE : View.GONE); + presentDeliveryStatus(messageRecord); + } + + public void setTextColor(int color) { + textColor = color; + dateView.setTextColor(color); + editedView.setTextColor(color); + bookmarkIndicatorView.setColorFilter(color); + emailIndicatorView.setColorFilter(color); + locationIndicatorView.setColorFilter(color); + deliveryStatusView.setTint(color); + } + + private void presentDate(@NonNull DcMsg dcMsg) { + dateView.forceLayout(); + Context context = getContext(); + String date = dcMsg.getType() == DcMsg.DC_MSG_CALL? + DateUtils.getExtendedTimeSpanString(context, dcMsg.getTimestamp()) + : DateUtils.getExtendedRelativeTimeSpanString(context, dcMsg.getTimestamp()); + if (callDuration > 0) { + String duration = DateUtils.getFormattedCallDuration(context, callDuration); + dateView.setText(context.getString(R.string.call_date_and_duration, date, duration)); + } else { + dateView.setText(date); + } + } + + private void presentDeliveryStatus(@NonNull DcMsg messageRecord) { + // isDownloading is temporary and should be checked first. + boolean isDownloading = messageRecord.getDownloadState() == DcMsg.DC_DOWNLOAD_IN_PROGRESS; + boolean isCall = messageRecord.getType() == DcMsg.DC_MSG_CALL; + + if (isDownloading) deliveryStatusView.setDownloading(); + else if (messageRecord.isPending()) deliveryStatusView.setPending(); + else if (messageRecord.isFailed()) deliveryStatusView.setFailed(); + else if (!messageRecord.isOutgoing() || isCall) deliveryStatusView.setNone(); + else if (messageRecord.isRemoteRead()) deliveryStatusView.setRead(); + else if (messageRecord.isDelivered()) deliveryStatusView.setSent(); + else if (messageRecord.isPreparing()) deliveryStatusView.setPreparing(); + else deliveryStatusView.setPending(); + + if (messageRecord.isFailed()) { + deliveryStatusView.setTint(Color.RED); + } else { + deliveryStatusView.setTint(textColor); // Reset the color to the standard color (because the footer is re-used in a RecyclerView) + } + } + + public String getDescription() { + String desc = dateView.getText().toString(); + String deliveryDesc = deliveryStatusView.getDescription(); + if (!"".equals(deliveryDesc)) { + desc += "\n" + deliveryDesc; + } + return desc; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java new file mode 100644 index 0000000000000000000000000000000000000000..ed8bf0255bc58c6f6928b239c8b3b70f0d6101ec --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.ExecutionException; + +import chat.delta.util.ListenableFuture; + +public class ConversationItemThumbnail extends FrameLayout { + + private static final Paint LIGHT_THEME_OUTLINE_PAINT = new Paint(); + private static final Paint DARK_THEME_OUTLINE_PAINT = new Paint(); + public static final double IMAGE_ASPECT_RATIO = 1.0; + + static { + LIGHT_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 0, 0, 0)); + LIGHT_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE); + LIGHT_THEME_OUTLINE_PAINT.setStrokeWidth(1f); + LIGHT_THEME_OUTLINE_PAINT.setAntiAlias(true); + + DARK_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 255, 255, 255)); + DARK_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE); + DARK_THEME_OUTLINE_PAINT.setStrokeWidth(1f); + DARK_THEME_OUTLINE_PAINT.setAntiAlias(true); + } + + private final float[] radii = new float[8]; + private final RectF bounds = new RectF(); + private final Path corners = new Path(); + + private ThumbnailView thumbnail; + private ImageView shade; + private ConversationItemFooter footer; + private Paint outlinePaint; + private CornerMask cornerMask; + private int naturalWidth; + private int naturalHeight; + + public ConversationItemThumbnail(Context context) { + super(context); + init(); + } + + public ConversationItemThumbnail(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + private void init() { + inflate(getContext(), R.layout.conversation_item_thumbnail, this); + + this.thumbnail = findViewById(R.id.conversation_thumbnail_image); + this.shade = findViewById(R.id.conversation_thumbnail_shade); + this.footer = findViewById(R.id.conversation_thumbnail_footer); + this.outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT; + this.cornerMask = new CornerMask(this); + + setTouchDelegate(thumbnail.getTouchDelegate()); + } + + public String getDescription() { + String desc = thumbnail.getDescription(); + if (footer.getVisibility() == View.VISIBLE) { + desc += "\n" + footer.getDescription(); + } + return desc; + } + + @Override + protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) { + int width = MeasureSpec.getSize(originalWidthMeasureSpec); + int minHeight = readDimen(R.dimen.media_bubble_min_height); + int availableHeight = (int) (getResources().getDisplayMetrics().heightPixels * 0.75); + + if (naturalWidth == 0 || naturalHeight == 0) { + super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec); + return; + } + + // Compute height: + int bestHeight = width * naturalHeight / naturalWidth; + int maxHeight = (int) (width * IMAGE_ASPECT_RATIO); + int height = Util.clamp(bestHeight, 0, maxHeight); + + height = Util.clamp(height, minHeight, availableHeight); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); + + super.onMeasure(originalWidthMeasureSpec, heightMeasureSpec); + } + + @SuppressWarnings("SuspiciousNameCombination") + @Override + protected void dispatchDraw(@NonNull Canvas canvas) { + + super.dispatchDraw(canvas); + + cornerMask.mask(canvas); + + final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2; + + bounds.left = halfStrokeWidth; + bounds.top = halfStrokeWidth; + bounds.right = canvas.getWidth() - halfStrokeWidth; + bounds.bottom = canvas.getHeight() - halfStrokeWidth; + + corners.reset(); + corners.addRoundRect(bounds, radii, Path.Direction.CW); + + canvas.drawPath(corners, outlinePaint); + } + + @Override + public void setFocusable(boolean focusable) { + thumbnail.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + thumbnail.setClickable(clickable); + } + + @Override + public void setOnLongClickListener(@Nullable OnLongClickListener l) { + thumbnail.setOnLongClickListener(l); + } + + public void showShade(boolean show) { + shade.setVisibility(show ? VISIBLE : GONE); + forceLayout(); + } + + public void setOutlineCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + + cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); + } + + public ConversationItemFooter getFooter() { + return footer; + } + + private void refreshSlideAttachmentState(ListenableFuture signal, Slide slide) { + signal.addListener(new ListenableFuture.Listener() { + @Override + public void onSuccess(Boolean result) { + slide.asAttachment().setTransferState(AttachmentDatabase.TRANSFER_PROGRESS_DONE); + } + + @Override + public void onFailure(ExecutionException e) { + slide.asAttachment().setTransferState(AttachmentDatabase.TRANSFER_PROGRESS_FAILED); + } + }); + } + + public void setThumbnailClickListener(SlideClickListener listener) { + thumbnail.setThumbnailClickListener(listener); + } + + @Override + public boolean performClick() { + return thumbnail.performClick(); + } + + @UiThread + public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, + int naturalWidth, int naturalHeight) + { + this.naturalWidth = naturalWidth; + this.naturalHeight = naturalHeight; + refreshSlideAttachmentState(thumbnail.setImageResource(glideRequests, slide), slide); + } + + public void clear(GlideRequests glideRequests) { + thumbnail.clear(glideRequests); + } + + private int readDimen(@DimenRes int dimenId) { + return getResources().getDimensionPixelOffset(dimenId); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java new file mode 100644 index 0000000000000000000000000000000000000000..ce5eeed81cfb94dbf18a1e16eb862ea3af94ecf5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import android.view.View; + +public class CornerMask { + + private final float[] radii = new float[8]; + private final Paint clearPaint = new Paint(); + private final Path outline = new Path(); + private final Path corners = new Path(); + private final RectF bounds = new RectF(); + + public CornerMask(@NonNull View view) { + view.setLayerType(View.LAYER_TYPE_HARDWARE, null); + + clearPaint.setColor(Color.BLACK); + clearPaint.setStyle(Paint.Style.FILL); + clearPaint.setAntiAlias(true); + clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } + + public void mask(Canvas canvas) { + bounds.left = 0; + bounds.top = 0; + bounds.right = canvas.getWidth(); + bounds.bottom = canvas.getHeight(); + + corners.reset(); + corners.addRoundRect(bounds, radii, Path.Direction.CW); + + // Note: There's a bug in the P beta where most PorterDuff modes aren't working. But CLEAR does. + // So we find and inverse path and use Mode.CLEAR for versions that support Path.op(). + // See issue https://issuetracker.google.com/issues/111394085. + outline.reset(); + outline.addRect(bounds, Path.Direction.CW); + outline.op(corners, Path.Op.DIFFERENCE); + canvas.drawPath(outline, clearPaint); + } + + public void setRadius(int radius) { + setRadii(radius, radius, radius, radius); + } + + public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) { + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + } + + public void setTopLeftRadius(int radius) { + radii[0] = radii[1] = radius; + } + + public void setTopRightRadius(int radius) { + radii[2] = radii[3] = radius; + } + + public void setBottomRightRadius(int radius) { + radii[4] = radii[5] = radius; + } + + public void setBottomLeftRadius(int radius) { + radii[6] = radii[7] = radius; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java b/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java new file mode 100644 index 0000000000000000000000000000000000000000..b3a2e2655a513a6a0adfd6e37f49da4fd4069088 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.components; + +import android.app.Dialog; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.DialogPreference; +import androidx.preference.PreferenceDialogFragmentCompat; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.CustomPreferenceValidator; +import org.thoughtcrime.securesms.util.Prefs; + +public class CustomDefaultPreference extends DialogPreference { + + private static final String TAG = CustomDefaultPreference.class.getSimpleName(); + + private final int inputType; + private final String customPreference; + private final String customToggle; + + private final CustomPreferenceValidator validator; + private String defaultValue; + + public CustomDefaultPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + int[] attributeNames = new int[]{android.R.attr.inputType, R.attr.custom_pref_toggle}; + try (TypedArray attributes = context.obtainStyledAttributes(attrs, attributeNames)) { + this.inputType = attributes.getInt(0, 0); + this.customPreference = getKey(); + this.customToggle = attributes.getString(1); + this.validator = new CustomDefaultPreferenceDialogFragmentCompat.NullValidator(); + } + + setPersistent(false); + setDialogLayoutResource(R.layout.custom_default_preference_dialog); + } + + public CustomDefaultPreference setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + this.setSummary(getSummary()); + return this; + } + + @Override + public String getSummary() { + if (isCustom()) { + return getContext().getString(R.string.pref_using_custom, + getPrettyPrintValue(getCustomValue())); + } else { + return getContext().getString(R.string.pref_using_default, + getPrettyPrintValue(getDefaultValue())); + } + } + + private String getPrettyPrintValue(String value) { + if (TextUtils.isEmpty(value)) return getContext().getString(R.string.none); + else return value; + } + + private boolean isCustom() { + return Prefs.getBooleanPreference(getContext(), customToggle, false); + } + + private void setCustom(boolean custom) { + Prefs.setBooleanPreference(getContext(), customToggle, custom); + } + + private String getCustomValue() { + return Prefs.getStringPreference(getContext(), customPreference, ""); + } + + private void setCustomValue(String value) { + Prefs.setStringPreference(getContext(), customPreference, value); + } + + private String getDefaultValue() { + return defaultValue; + } + + + public static class CustomDefaultPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat { + + private static final String INPUT_TYPE = "input_type"; + + private Spinner spinner; + private EditText customText; + private TextView defaultLabel; + + public static CustomDefaultPreferenceDialogFragmentCompat newInstance(String key) { + CustomDefaultPreferenceDialogFragmentCompat fragment = new CustomDefaultPreferenceDialogFragmentCompat(); + Bundle b = new Bundle(1); + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + @Override + protected void onBindDialogView(@NonNull View view) { + Log.w(TAG, "onBindDialogView"); + super.onBindDialogView(view); + + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + this.spinner = (Spinner) view.findViewById(R.id.default_or_custom); + this.defaultLabel = (TextView) view.findViewById(R.id.default_label); + this.customText = (EditText) view.findViewById(R.id.custom_edit); + + this.customText.setInputType(preference.inputType); + this.customText.addTextChangedListener(new TextValidator()); + this.customText.setText(preference.getCustomValue()); + this.spinner.setOnItemSelectedListener(new SelectionLister()); + this.defaultLabel.setText(preference.getPrettyPrintValue(preference.defaultValue)); + } + + + @NonNull + @Override + public Dialog onCreateDialog(Bundle instanceState) { + Dialog dialog = super.onCreateDialog(instanceState); + + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (preference.isCustom()) spinner.setSelection(1, true); + else spinner.setSelection(0, true); + + return dialog; + } + + @Override + public void onDialogClosed(boolean positiveResult) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (positiveResult) { + if (spinner != null) preference.setCustom(spinner.getSelectedItemPosition() == 1); + if (customText != null) preference.setCustomValue(customText.getText().toString()); + + preference.setSummary(preference.getSummary()); + } + } + + interface CustomPreferenceValidator { + public boolean isValid(String value); + } + + private static class NullValidator implements CustomPreferenceValidator { + @Override + public boolean isValid(String value) { + return true; + } + } + + private class TextValidator implements TextWatcher { + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (spinner.getSelectedItemPosition() == 1) { + Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setEnabled(preference.validator.isValid(s.toString())); + } + } + } + + private class SelectionLister implements AdapterView.OnItemSelectedListener { + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); + + defaultLabel.setVisibility(position == 0 ? View.VISIBLE : View.GONE); + customText.setVisibility(position == 0 ? View.GONE : View.VISIBLE); + positiveButton.setEnabled(position == 0 || preference.validator.isValid(customText.getText().toString())); + } + + @Override + public void onNothingSelected(AdapterView parent) { + defaultLabel.setVisibility(View.VISIBLE); + customText.setVisibility(View.GONE); + } + } + + } + + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java new file mode 100644 index 0000000000000000000000000000000000000000..b4db7fb682fb02f3f10f71ab5025a8dd83cd393b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.LinearInterpolator; +import android.view.animation.RotateAnimation; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.AccessibilityUtil; + +public class DeliveryStatusView { + + private final ImageView deliveryIndicator; + private final Context context; + private static RotateAnimation prepareAnimation; + private static RotateAnimation sendingAnimation; + private boolean animated; + + public DeliveryStatusView(ImageView deliveryIndicator) { + this.deliveryIndicator = deliveryIndicator; + this.context = deliveryIndicator.getContext(); + } + + private void animatePrepare() + { + if (AccessibilityUtil.areAnimationsDisabled(context)) return; + + if(prepareAnimation ==null) { + prepareAnimation = new RotateAnimation(360f, 0f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + prepareAnimation.setInterpolator(new LinearInterpolator()); + prepareAnimation.setDuration(2500); + prepareAnimation.setRepeatCount(Animation.INFINITE); + } + + deliveryIndicator.startAnimation(prepareAnimation); + animated = true; + } + + private void animateSending() + { + if (AccessibilityUtil.areAnimationsDisabled(context)) return; + + if(sendingAnimation ==null) { + sendingAnimation = new RotateAnimation(0, 360f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + sendingAnimation.setInterpolator(new LinearInterpolator()); + sendingAnimation.setDuration(1500); + sendingAnimation.setRepeatCount(Animation.INFINITE); + } + + deliveryIndicator.startAnimation(sendingAnimation); + animated = true; + } + + private void clearAnimation() + { + if(animated) { + deliveryIndicator.clearAnimation(); + animated = false; + } + } + + public void setNone() { + deliveryIndicator.setVisibility(View.GONE); + deliveryIndicator.clearAnimation(); + } + + public void setDownloading() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_sending); + deliveryIndicator.setContentDescription(context.getString(R.string.one_moment)); + animatePrepare(); + } + + public void setPreparing() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_sending); + deliveryIndicator.setContentDescription(context.getString(R.string.a11y_delivery_status_sending)); + animatePrepare(); + } + + public void setPending() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_sending); + deliveryIndicator.setContentDescription(context.getString(R.string.a11y_delivery_status_sending)); + animateSending(); + } + + public void setSent() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_sent); + deliveryIndicator.setContentDescription(context.getString(R.string.a11y_delivery_status_delivered)); + clearAnimation(); + } + + public void setRead() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_read); + deliveryIndicator.setContentDescription(context.getString(R.string.a11y_delivery_status_read)); + clearAnimation(); + } + + public void setFailed() { + deliveryIndicator.setVisibility(View.VISIBLE); + deliveryIndicator.setImageResource(R.drawable.ic_delivery_status_failed); + deliveryIndicator.setContentDescription(context.getString(R.string.a11y_delivery_status_invalid)); + clearAnimation(); + } + + public void setTint(Integer color) { + if (color != null) { + deliveryIndicator.setColorFilter(color); + } else { + resetTint(); + } + } + + public void resetTint() { + deliveryIndicator.setColorFilter(null); + } + + public String getDescription() { + if (deliveryIndicator.getVisibility() == View.VISIBLE) { + return deliveryIndicator.getContentDescription().toString(); + } + return ""; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java new file mode 100644 index 0000000000000000000000000000000000000000..a9b86c1a0733444ad43d7321fed3bd0b3e4277f3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.guava.Optional; + +public class DocumentView extends FrameLayout { + + private final @NonNull TextView fileName; + private final @NonNull TextView fileSize; + + private @Nullable SlideClickListener viewListener; + + public DocumentView(@NonNull Context context) { + this(context, null); + } + + public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.document_view, this); + + this.fileName = findViewById(R.id.file_name); + this.fileSize = findViewById(R.id.file_size); + } + + public void setDocumentClickListener(@Nullable SlideClickListener listener) { + this.viewListener = listener; + } + + public void setDocument(final @NonNull DocumentSlide documentSlide) + { + this.fileName.setText(documentSlide.getFileName().or(getContext().getString(R.string.unknown))); + + String fileSize = Util.getPrettyFileSize(documentSlide.getFileSize()) + + " " + getFileType(documentSlide.getFileName()).toUpperCase(); + this.fileSize.setText(fileSize); + + this.setOnClickListener(new OpenClickedListener(documentSlide)); + } + + public String getDescription() { + String desc = getContext().getString(R.string.file); + desc += "\n" + fileName.getText(); + desc += "\n" + fileSize.getText(); + return desc; + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + } + + private @NonNull String getFileType(Optional fileName) { + if (!fileName.isPresent()) return ""; + + String[] parts = fileName.get().split("\\."); + + if (parts.length < 2) { + return ""; + } + + String suffix = parts[parts.length - 1]; + + if (suffix.length() <= 4) { + return suffix; + } + + return ""; + } + + private class OpenClickedListener implements View.OnClickListener { + private final @NonNull DocumentSlide slide; + + private OpenClickedListener(@NonNull DocumentSlide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (viewListener != null) { + viewListener.onClick(v, slide); + } + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java new file mode 100644 index 0000000000000000000000000000000000000000..bdfa2782e744e2125250a1eb339d14102e8977ce --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Typeface; + +import androidx.appcompat.widget.AppCompatTextView; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.util.AttributeSet; +import android.view.View; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ResUtil; +import org.thoughtcrime.securesms.util.spans.CenterAlignedRelativeSizeSpan; + +public class FromTextView extends AppCompatTextView { + + public FromTextView(Context context) { + super(context); + } + + public FromTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setText(Recipient recipient) { + setText(recipient, true); + } + + public void setText(Recipient recipient, boolean read) { + String fromString = recipient.toShortString(); + + int typeface; + + if (!read) { + typeface = Typeface.BOLD; + } else { + typeface = Typeface.NORMAL; + } + + SpannableStringBuilder builder = new SpannableStringBuilder(); + + SpannableString fromSpan = new SpannableString(fromString); + fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + + if (recipient.getName() == null && !TextUtils.isEmpty(recipient.getProfileName())) { + SpannableString profileName = new SpannableString(" (~" + recipient.getProfileName() + ") "); + profileName.setSpan(new CenterAlignedRelativeSizeSpan(0.75f), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + profileName.setSpan(new TypefaceSpan("sans-serif-light"), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + profileName.setSpan(new ForegroundColorSpan(ResUtil.getColor(getContext(), R.attr.conversation_list_item_subject_color)), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + if (this.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + builder.append(profileName); + builder.append(fromSpan); + } else { + builder.append(fromSpan); + builder.append(profileName); + } + } else { + builder.append(fromSpan); + } + + setText(builder); + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java b/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java new file mode 100644 index 0000000000000000000000000000000000000000..9d47e18feacc9023d4126493f5463ad4efcb3cba --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.widget.ImageView; + +import com.bumptech.glide.request.target.DrawableImageViewTarget; + +import chat.delta.util.SettableFuture; + +public class GlideDrawableListeningTarget extends DrawableImageViewTarget { + + private final SettableFuture loaded; + + public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture loaded) { + super(view); + this.loaded = loaded; + } + + @Override + protected void setResource(@Nullable Drawable resource) { + super.setResource(resource); + loaded.set(true); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + super.onLoadFailed(errorDrawable); + loaded.set(true); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java b/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java new file mode 100644 index 0000000000000000000000000000000000000000..01c78690f1ef9ac6fd8ce902b81ffb741d4e1554 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; + +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import android.util.AttributeSet; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.widget.LinearLayout; + +public class HidingLinearLayout extends LinearLayout { + + public HidingLinearLayout(Context context) { + super(context); + } + + public HidingLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void hide() { + if (!isEnabled() || getVisibility() == GONE) return; + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new ScaleAnimation(1, 0, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); + animation.addAnimation(new AlphaAnimation(1, 0)); + animation.setDuration(100); + + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + setVisibility(GONE); + } + }); + + animateWith(animation); + } + + public void show() { + if (!isEnabled() || getVisibility() == VISIBLE) return; + + setVisibility(VISIBLE); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new ScaleAnimation(0, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); + animation.addAnimation(new AlphaAnimation(0, 1)); + animation.setDuration(100); + + animateWith(animation); + } + + private void animateWith(Animation animation) { + animation.setDuration(150); + animation.setInterpolator(new FastOutSlowInInterpolator()); + startAnimation(animation); + } + + public void disable() { + setVisibility(GONE); + setEnabled(false); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java b/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java new file mode 100644 index 0000000000000000000000000000000000000000..9ccd0793307da87224b6a799eedd583727817fcf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.widget.EditText; + +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; +import org.thoughtcrime.securesms.util.ServiceUtil; + +public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener { + private InputView current; + + public InputAwareLayout(Context context) { + this(context, null); + } + + public InputAwareLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InputAwareLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + addOnKeyboardShownListener(this); + } + + @Override public void onKeyboardShown() { + hideAttachedInput(true); + } + + public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) { + if (isKeyboardOpen()) { + hideSoftkey(imeTarget, () -> { + hideAttachedInput(true); + input.show(getKeyboardHeight(), true); + current = input; + }); + } else { + if (current != null) current.hide(true); + input.show(getKeyboardHeight(), current != null); + current = input; + } + } + + public InputView getCurrentInput() { + return current; + } + + public void hideCurrentInput(EditText imeTarget) { + if (isKeyboardOpen()) hideSoftkey(imeTarget, null); + else hideAttachedInput(false); + } + + public void hideAttachedInput(boolean instant) { + if (current != null) current.hide(instant); + current = null; + } + + public boolean isInputOpen() { + return (isKeyboardOpen() || (current != null && current.isShowing())); + } + + public void showSoftkey(final EditText inputTarget) { + postOnKeyboardOpen(() -> hideAttachedInput(true)); + inputTarget.post(() -> { + inputTarget.requestFocus(); + ServiceUtil.getInputMethodManager(inputTarget.getContext()).showSoftInput(inputTarget, 0); + }); + } + + private void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) { + if (runAfterClose != null) postOnKeyboardClose(runAfterClose); + + ServiceUtil.getInputMethodManager(inputTarget.getContext()) + .hideSoftInputFromWindow(inputTarget.getWindowToken(), 0); + } + + public interface InputView { + void show(int height, boolean immediate); + void hide(boolean immediate); + boolean isShowing(); + } +} + diff --git a/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java new file mode 100644 index 0000000000000000000000000000000000000000..16cfb012d1e5cab4ca2091b053cfc5900a1c1101 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -0,0 +1,431 @@ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.TranslateAnimation; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.components.emoji.EmojiToggle; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.guava.Optional; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + +public class InputPanel extends ConstraintLayout + implements MicrophoneRecorderView.Listener, + KeyboardAwareLinearLayout.OnKeyboardShownListener, + MediaKeyboard.MediaKeyboardListener +{ + + private static final String TAG = InputPanel.class.getSimpleName(); + + private static final int FADE_TIME = 150; + + private QuoteView quoteView; + private EmojiToggle emojiToggle; + private ComposeText composeText; + private View quickCameraToggle; + private View quickAudioToggle; + private View buttonToggle; + private View recordingContainer; + private View recordLockCancel; + + private MicrophoneRecorderView microphoneRecorderView; + private SlideToCancel slideToCancel; + private RecordTime recordTime; + private ValueAnimator quoteAnimator; + + private @Nullable Listener listener; + + public InputPanel(Context context) { + super(context); + } + + public InputPanel(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + View quoteDismiss = findViewById(R.id.quote_dismiss); + + this.quoteView = findViewById(R.id.quote_view); + this.emojiToggle = findViewById(R.id.emoji_toggle); + this.composeText = findViewById(R.id.embedded_text_editor); + this.quickCameraToggle = findViewById(R.id.quick_camera_toggle); + this.quickAudioToggle = findViewById(R.id.quick_audio_toggle); + this.buttonToggle = findViewById(R.id.button_toggle); + this.recordingContainer = findViewById(R.id.recording_container); + this.recordLockCancel = findViewById(R.id.record_cancel); + this.recordTime = new RecordTime(findViewById(R.id.record_time)); + this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel)); + this.microphoneRecorderView = findViewById(R.id.recorder_view); + this.microphoneRecorderView.setListener(this); + + this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction()); + + quoteDismiss.setOnClickListener(v -> clearQuote()); + } + + public void setListener(final @NonNull Listener listener) { + this.listener = listener; + + emojiToggle.setOnClickListener(v -> listener.onEmojiToggle()); + } + + public void setMediaListener(@NonNull MediaListener listener) { + composeText.setMediaListener(listener); + } + + public void setQuote(@NonNull GlideRequests glideRequests, + @NonNull DcMsg msg, + long id, + @NonNull Recipient author, + @NonNull CharSequence body, + @NonNull SlideDeck attachments, + @NonNull boolean isEdit) + { + this.quoteView.setQuote(glideRequests, msg, author, body, attachments, false, isEdit); + + int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight() + : 0; + + this.quoteView.setVisibility(VISIBLE); + this.quoteView.measure(0, 0); + + if (quoteAnimator != null) { + quoteAnimator.cancel(); + } + + quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null); + + quoteAnimator.start(); + } + + public void clearQuoteWithoutAnimation() { + quoteView.dismiss(); + if (listener != null) listener.onQuoteDismissed(); + } + + public void clearQuote() { + if (quoteAnimator != null) { + quoteAnimator.cancel(); + } + + quoteAnimator = createHeightAnimator(quoteView, quoteView.getMeasuredHeight(), 0, new AnimationCompleteListener() { + @Override + public void onAnimationEnd(@NonNull Animator animation) { + quoteView.dismiss(); + if (listener != null) listener.onQuoteDismissed(); + } + }); + + quoteAnimator.start(); + } + + private static ValueAnimator createHeightAnimator(@NonNull View view, + int originalHeight, + int finalHeight, + @Nullable AnimationCompleteListener onAnimationComplete) + { + ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight) + .setDuration(200); + + animator.addUpdateListener(animation -> { + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.height = Math.max(1, (int) animation.getAnimatedValue()); + view.setLayoutParams(params); + }); + + if (onAnimationComplete != null) { + animator.addListener(onAnimationComplete); + } + + return animator; + } + + public Optional getQuote() { + if (quoteView.getVisibility() == View.VISIBLE && quoteView.getBody() != null) { + return Optional.of(new QuoteModel( + quoteView.getDcContact(), quoteView.getBody().toString(), + quoteView.getAttachments(), quoteView.getOriginalMsg() + )); + } else { + return Optional.absent(); + } + } + + public void clickOnComposeInput() { + composeText.performClick(); + } + + public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) { + mediaKeyboard.setKeyboardListener(this); + } + + @Override + public void onRecordPermissionRequired() { + if (listener != null) listener.onRecorderPermissionRequired(); + } + + @Override + public void onRecordPressed() { + if (listener != null) listener.onRecorderStarted(); + recordTime.display(); + slideToCancel.display(); + + ViewUtil.fadeOut(emojiToggle, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE); + buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start(); + } + + @Override + public void onRecordReleased() { + long elapsedTime = onRecordHideEvent(); + + if (listener != null) { + Log.d(TAG, "Elapsed time: " + elapsedTime); + if (elapsedTime > 1000) { + listener.onRecorderFinished(); + } else { + Toast.makeText(getContext(), R.string.chat_record_explain, Toast.LENGTH_LONG).show(); + listener.onRecorderCanceled(); + } + } + } + + @Override + public void onRecordMoved(float offsetX, float absoluteX) { + slideToCancel.moveTo(offsetX); + + float position = absoluteX / recordingContainer.getWidth(); + + if (ViewUtil.isLtr(this) && position <= 0.5 || + ViewUtil.isRtl(this) && position >= 0.6) + { + this.microphoneRecorderView.cancelAction(); + } + } + + @Override + public void onRecordCanceled() { + onRecordHideEvent(); + if (listener != null) listener.onRecorderCanceled(); + } + + @Override + public void onRecordLocked() { + slideToCancel.hide(); + recordLockCancel.setVisibility(View.VISIBLE); + buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + if (listener != null) listener.onRecorderLocked(); + } + + public void onPause() { + this.microphoneRecorderView.cancelAction(); + } + + public void setEnabled(boolean enabled) { + composeText.setEnabled(enabled); + emojiToggle.setEnabled(enabled); + quickAudioToggle.setEnabled(enabled); + quickCameraToggle.setEnabled(enabled); + } + + private long onRecordHideEvent() { + recordLockCancel.setVisibility(View.GONE); + + ListenableFuture future = slideToCancel.hide(); + long elapsedTime = recordTime.hide(); + + future.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Void result) { + ViewUtil.fadeIn(emojiToggle, FADE_TIME); + ViewUtil.fadeIn(composeText, FADE_TIME); + ViewUtil.fadeIn(quickCameraToggle, FADE_TIME); + ViewUtil.fadeIn(quickAudioToggle, FADE_TIME); + buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + composeText.requestFocus(); + } + }); + + return elapsedTime; + } + + @Override + public void onKeyboardShown() { + emojiToggle.setToMedia(); + } + + public boolean isRecordingInLockedMode() { + return microphoneRecorderView.isRecordingLocked(); + } + + public void releaseRecordingLock() { + microphoneRecorderView.unlockAction(); + } + + @Override + public void onShown() { + emojiToggle.setToIme(); + } + + @Override + public void onHidden() { + emojiToggle.setToMedia(); + } + + @Override + public void onEmojiPicked(String emoji) { + final int start = composeText.getSelectionStart(); + final int end = composeText.getSelectionEnd(); + + composeText.getText().replace(Math.min(start, end), Math.max(start, end), emoji); + composeText.setSelection(start + emoji.length()); + } + + public interface Listener { + void onRecorderStarted(); + void onRecorderLocked(); + void onRecorderFinished(); + void onRecorderCanceled(); + void onRecorderPermissionRequired(); + void onEmojiToggle(); + void onQuoteDismissed(); + } + + private static class SlideToCancel { + + private final View slideToCancelView; + + SlideToCancel(View slideToCancelView) { + this.slideToCancelView = slideToCancelView; + } + + public void display() { + ViewUtil.fadeIn(this.slideToCancelView, FADE_TIME); + } + + public ListenableFuture hide() { + final SettableFuture future = new SettableFuture<>(); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, slideToCancelView.getTranslationX(), + Animation.ABSOLUTE, 0, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0)); + animation.addAnimation(new AlphaAnimation(1, 0)); + + animation.setDuration(MicrophoneRecorderView.ANIMATION_DURATION); + animation.setFillBefore(true); + animation.setFillAfter(false); + + slideToCancelView.postDelayed(() -> future.set(null), MicrophoneRecorderView.ANIMATION_DURATION); + slideToCancelView.setVisibility(View.GONE); + slideToCancelView.startAnimation(animation); + + return future; + } + + void moveTo(float offset) { + Animation animation = new TranslateAnimation(Animation.ABSOLUTE, offset, + Animation.ABSOLUTE, offset, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0); + + animation.setDuration(0); + animation.setFillAfter(true); + animation.setFillBefore(true); + + slideToCancelView.startAnimation(animation); + } + } + + private static class RecordTime implements Runnable { + + private final TextView recordTimeView; + private final AtomicLong startTime = new AtomicLong(0); + private final int UPDATE_EVERY_MS = 99; + + private RecordTime(TextView recordTimeView) { + this.recordTimeView = recordTimeView; + } + + public void display() { + this.startTime.set(System.currentTimeMillis()); + this.recordTimeView.setText(formatElapsedTime(0)); + ViewUtil.fadeIn(this.recordTimeView, FADE_TIME); + Util.runOnMainDelayed(this, UPDATE_EVERY_MS); + } + + public long hide() { + long elapsedtime = System.currentTimeMillis() - startTime.get(); + this.startTime.set(0); + ViewUtil.fadeOut(this.recordTimeView, FADE_TIME, View.INVISIBLE); + return elapsedtime; + } + + @Override + public void run() { + long localStartTime = startTime.get(); + if (localStartTime > 0) { + long elapsedTime = System.currentTimeMillis() - localStartTime; + recordTimeView.setText(formatElapsedTime(elapsedTime)); + Util.runOnMainDelayed(this, UPDATE_EVERY_MS); + } + } + + private String formatElapsedTime(long ms) + { + return DateUtils.formatElapsedTime(TimeUnit.MILLISECONDS.toSeconds(ms)) + + String.format(".%01d", ((ms/100)%10)); + + } + } + + public interface MediaListener { + void onMediaSelected(@NonNull Uri uri, String contentType); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java b/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java new file mode 100644 index 0000000000000000000000000000000000000000..7c3d499cc1b95e9cfb5a3e20fb9e88e2c4437543 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java @@ -0,0 +1,311 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.util.DisplayMetrics; +import android.view.Surface; +import android.view.View; +import android.view.WindowInsets; + +import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.preference.PreferenceManager; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; + +/** + * LinearLayout that, when a view container, will report back when it thinks a soft keyboard + * has been opened and what its height would be. + */ +public class KeyboardAwareLinearLayout extends LinearLayoutCompat { + private static final String TAG = KeyboardAwareLinearLayout.class.getSimpleName(); + + private static final long KEYBOARD_DEBOUNCE = 150; + + private final Rect rect = new Rect(); + private final Set hiddenListeners = new HashSet<>(); + private final Set shownListeners = new HashSet<>(); + private final DisplayMetrics displayMetrics = new DisplayMetrics(); + + private final int minKeyboardSize; + private final int minCustomKeyboardSize; + private final int defaultCustomKeyboardSize; + private final int minCustomKeyboardTopMarginPortrait; + private final int minCustomKeyboardTopMarginLandscape; + private final int statusBarHeight; + + private int viewInset; + + private boolean keyboardOpen = false; + private int rotation = 0; + private long openedAt = 0; + + public KeyboardAwareLinearLayout(Context context) { + this(context, null); + } + + public KeyboardAwareLinearLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public KeyboardAwareLinearLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + minKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_keyboard_size); + minCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_size); + defaultCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.default_custom_keyboard_size); + minCustomKeyboardTopMarginPortrait = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); + minCustomKeyboardTopMarginLandscape = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); + statusBarHeight = ViewUtil.getStatusBarHeight(this); + viewInset = getViewInset(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + updateRotation(); + updateKeyboardState(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private void updateRotation() { + int oldRotation = rotation; + rotation = getDeviceRotation(); + if (oldRotation != rotation) { + Log.i(TAG, "rotation changed"); + onKeyboardClose(); + } + } + + private void updateKeyboardState() { + if (viewInset == 0) viewInset = getViewInset(); + + getWindowVisibleDisplayFrame(rect); + + final int availableHeight = getAvailableHeight(); + final int keyboardHeight = availableHeight - rect.bottom; + + if (keyboardHeight > minKeyboardSize) { + if (getKeyboardHeight() != keyboardHeight) { + if (isLandscape()) { + setKeyboardLandscapeHeight(keyboardHeight); + } else { + setKeyboardPortraitHeight(keyboardHeight); + } + } + if (!keyboardOpen) { + onKeyboardOpen(keyboardHeight); + } + } else if (keyboardOpen) { + onKeyboardClose(); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + rotation = getDeviceRotation(); + if (Build.VERSION.SDK_INT >= 23 && getRootWindowInsets() != null) { + int bottomInset; + WindowInsets windowInsets = getRootWindowInsets(); + + if (Build.VERSION.SDK_INT >= 30) { + bottomInset = windowInsets.getInsets(WindowInsets.Type.navigationBars()).bottom; + } else { + bottomInset = windowInsets.getStableInsetBottom(); + } + + if (bottomInset != 0 && (viewInset == 0 || viewInset == statusBarHeight)) { + Log.i(TAG, "Updating view inset based on WindowInsets. viewInset: " + viewInset + " windowInset: " + bottomInset); + viewInset = bottomInset; + } + } + } + + private int getViewInset() { + try { + Field attachInfoField = View.class.getDeclaredField("mAttachInfo"); + attachInfoField.setAccessible(true); + Object attachInfo = attachInfoField.get(this); + if (attachInfo != null) { + Field stableInsetsField = attachInfo.getClass().getDeclaredField("mStableInsets"); + stableInsetsField.setAccessible(true); + Rect insets = (Rect) stableInsetsField.get(attachInfo); + if (insets != null) { + return insets.bottom; + } + } + } catch (NoSuchFieldException | IllegalAccessException e) { + // Do nothing + } + return statusBarHeight; + } + + private int getAvailableHeight() { + final int availableHeight = this.getRootView().getHeight() - viewInset; + final int availableWidth = this.getRootView().getWidth(); + + if (isLandscape() && availableHeight > availableWidth) { + //noinspection SuspiciousNameCombination + return availableWidth; + } + + return availableHeight; + } + + protected void onKeyboardOpen(int keyboardHeight) { + Log.i(TAG, "onKeyboardOpen(" + keyboardHeight + ")"); + keyboardOpen = true; + openedAt = System.currentTimeMillis(); + + notifyShownListeners(); + } + + protected void onKeyboardClose() { + if (System.currentTimeMillis() - openedAt < KEYBOARD_DEBOUNCE) { + Log.i(TAG, "Delaying onKeyboardClose()"); + postDelayed(this::updateKeyboardState, KEYBOARD_DEBOUNCE); + return; + } + + Log.i(TAG, "onKeyboardClose()"); + keyboardOpen = false; + openedAt = 0; + notifyHiddenListeners(); + } + + public boolean isKeyboardOpen() { + return keyboardOpen; + } + + public int getKeyboardHeight() { + return isLandscape() ? getKeyboardLandscapeHeight() : getKeyboardPortraitHeight(); + } + + public boolean isLandscape() { + int rotation = getDeviceRotation(); + return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270; + } + + private int getDeviceRotation() { + if (isInEditMode()) { + return Surface.ROTATION_0; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getContext().getDisplay().getRealMetrics(displayMetrics); + } else { + ServiceUtil.getWindowManager(getContext()).getDefaultDisplay().getRealMetrics(displayMetrics); + } + return displayMetrics.widthPixels > displayMetrics.heightPixels ? Surface.ROTATION_90 : Surface.ROTATION_0; + } + + private int getKeyboardLandscapeHeight() { + int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) + .getInt("keyboard_height_landscape", defaultCustomKeyboardSize); + return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginLandscape); + } + + private int getKeyboardPortraitHeight() { + int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) + .getInt("keyboard_height_portrait", defaultCustomKeyboardSize); + return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginPortrait); + } + + private void setKeyboardPortraitHeight(int height) { + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit().putInt("keyboard_height_portrait", height).apply(); + } + + private void setKeyboardLandscapeHeight(int height) { + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit().putInt("keyboard_height_landscape", height).apply(); + } + + public void postOnKeyboardClose(final Runnable runnable) { + if (keyboardOpen) { + addOnKeyboardHiddenListener(new OnKeyboardHiddenListener() { + @Override public void onKeyboardHidden() { + removeOnKeyboardHiddenListener(this); + runnable.run(); + } + }); + } else { + runnable.run(); + } + } + + public void postOnKeyboardOpen(final Runnable runnable) { + if (!keyboardOpen) { + addOnKeyboardShownListener(new OnKeyboardShownListener() { + @Override public void onKeyboardShown() { + removeOnKeyboardShownListener(this); + runnable.run(); + } + }); + } else { + runnable.run(); + } + } + + public void addOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { + hiddenListeners.add(listener); + } + + public void removeOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { + hiddenListeners.remove(listener); + } + + public void addOnKeyboardShownListener(OnKeyboardShownListener listener) { + shownListeners.add(listener); + } + + public void removeOnKeyboardShownListener(OnKeyboardShownListener listener) { + shownListeners.remove(listener); + } + + private void notifyHiddenListeners() { + final Set listeners = new HashSet<>(hiddenListeners); + for (OnKeyboardHiddenListener listener : listeners) { + listener.onKeyboardHidden(); + } + } + + private void notifyShownListeners() { + final Set listeners = new HashSet<>(shownListeners); + for (OnKeyboardShownListener listener : listeners) { + listener.onKeyboardShown(); + } + } + + public interface OnKeyboardHiddenListener { + void onKeyboardHidden(); + } + + public interface OnKeyboardShownListener { + void onKeyboardShown(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/MediaView.java b/src/main/java/org/thoughtcrime/securesms/components/MediaView.java new file mode 100644 index 0000000000000000000000000000000000000000..bd335218f0838b2ad679e91fafc47c334613556f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/MediaView.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.view.View; +import android.view.Window; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.video.VideoPlayer; + +import java.io.IOException; + +public class MediaView extends FrameLayout { + + private ZoomingImageView imageView; + private Stub videoView; + + public MediaView(@NonNull Context context) { + super(context); + initialize(); + } + + public MediaView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public MediaView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public MediaView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.media_view, this); + + this.imageView = findViewById(R.id.image); + this.videoView = new Stub<>(findViewById(R.id.video_player_stub)); + } + + public void set(@NonNull GlideRequests glideRequests, + @NonNull Window window, + @NonNull Uri source, + @Nullable String fileName, + @NonNull String mediaType, + long size, + boolean autoplay) + throws IOException + { + if (mediaType.startsWith("image/")) { + imageView.setVisibility(View.VISIBLE); + if (videoView.resolved()) videoView.get().setVisibility(View.GONE); + imageView.setImageUri(glideRequests, source, mediaType); + } else if (mediaType.startsWith("video/")) { + imageView.setVisibility(View.GONE); + videoView.get().setVisibility(View.VISIBLE); + videoView.get().setWindow(window); + videoView.get().setVideoSource(new VideoSlide(getContext(), source, fileName, size), autoplay); + } else { + throw new IOException("Unsupported media type: " + mediaType); + } + } + + public void pause() { + if (this.videoView.resolved()){ + this.videoView.get().pause(); + } + } + + public void cleanup() { + this.imageView.cleanup(); + if (this.videoView.resolved()) { + this.videoView.get().cleanup(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java b/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java new file mode 100644 index 0000000000000000000000000000000000000000..4c9dfe638e4cf63b9b32223edc9dcab68b65bf19 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java @@ -0,0 +1,270 @@ +package org.thoughtcrime.securesms.components; + +import android.Manifest; +import android.content.Context; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.AnticipateOvershootInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.LinearInterpolator; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ViewUtil; + +public final class MicrophoneRecorderView extends FrameLayout implements View.OnTouchListener { + + enum State { + NOT_RUNNING, + RUNNING_HELD, + RUNNING_LOCKED + } + + public static final int ANIMATION_DURATION = 200; + + private FloatingRecordButton floatingRecordButton; + private LockDropTarget lockDropTarget; + private @Nullable Listener listener; + private @NonNull State state = State.NOT_RUNNING; + + public MicrophoneRecorderView(Context context) { + super(context); + } + + public MicrophoneRecorderView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + floatingRecordButton = new FloatingRecordButton(getContext(), findViewById(R.id.quick_audio_fab)); + lockDropTarget = new LockDropTarget (getContext(), findViewById(R.id.lock_drop_target)); + + View recordButton = findViewById(R.id.quick_audio_toggle); + recordButton.setOnTouchListener(this); + } + + public void cancelAction() { + if (state != State.NOT_RUNNING) { + state = State.NOT_RUNNING; + hideUi(); + + if (listener != null) listener.onRecordCanceled(); + } + } + + public boolean isRecordingLocked() { + return state == State.RUNNING_LOCKED; + } + + private void lockAction() { + if (state == State.RUNNING_HELD) { + state = State.RUNNING_LOCKED; + hideUi(); + + if (listener != null) listener.onRecordLocked(); + } + } + + public void unlockAction() { + if (state == State.RUNNING_LOCKED) { + state = State.NOT_RUNNING; + hideUi(); + + if (listener != null) listener.onRecordReleased(); + } + } + + private void hideUi() { + floatingRecordButton.hide(); + lockDropTarget.hide(); + } + + @Override + public boolean onTouch(View v, final MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) { + if (listener != null) listener.onRecordPermissionRequired(); + } else { + state = State.RUNNING_HELD; + floatingRecordButton.display(event.getX(), event.getY()); + lockDropTarget.display(); + if (listener != null) listener.onRecordPressed(); + } + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (this.state == State.RUNNING_HELD) { + state = State.NOT_RUNNING; + hideUi(); + if (listener != null) listener.onRecordReleased(); + } + break; + case MotionEvent.ACTION_MOVE: + if (this.state == State.RUNNING_HELD) { + this.floatingRecordButton.moveTo(event.getX(), event.getY()); + if (listener != null) listener.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX()); + + int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target); + if (floatingRecordButton.lastOffsetY <= dimensionPixelSize) { + lockAction(); + } + } + break; + } + + return false; + } + + public void setListener(@Nullable Listener listener) { + this.listener = listener; + } + + public interface Listener { + void onRecordPressed(); + void onRecordReleased(); + void onRecordCanceled(); + void onRecordLocked(); + void onRecordMoved(float offsetX, float absoluteX); + void onRecordPermissionRequired(); + } + + private static class FloatingRecordButton { + + private final ImageView recordButtonFab; + + private float startPositionX; + private float startPositionY; + private float lastOffsetX; + private float lastOffsetY; + + FloatingRecordButton(Context context, ImageView recordButtonFab) { + this.recordButtonFab = recordButtonFab; + this.recordButtonFab.getBackground().setColorFilter(context.getResources() + .getColor(R.color.audio_icon), + PorterDuff.Mode.SRC_IN); + } + + void display(float x, float y) { + this.startPositionX = x; + this.startPositionY = y; + + recordButtonFab.setVisibility(View.VISIBLE); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0)); + + animation.addAnimation(new ScaleAnimation(.5f, 1f, .5f, 1f, + Animation.RELATIVE_TO_SELF, .5f, + Animation.RELATIVE_TO_SELF, .5f)); + + animation.setDuration(ANIMATION_DURATION); + animation.setInterpolator(new OvershootInterpolator()); + + recordButtonFab.startAnimation(animation); + } + + void moveTo(float x, float y) { + lastOffsetX = getXOffset(x); + lastOffsetY = getYOffset(y); + + if (Math.abs(lastOffsetX) > Math.abs(lastOffsetY)) { + lastOffsetY = 0; + } else { + lastOffsetX = 0; + } + + recordButtonFab.setTranslationX(lastOffsetX); + recordButtonFab.setTranslationY(lastOffsetY); + } + + void hide() { + recordButtonFab.setTranslationX(0); + recordButtonFab.setTranslationY(0); + if (recordButtonFab.getVisibility() != VISIBLE) return; + + AnimationSet animation = new AnimationSet(false); + Animation scaleAnimation = new ScaleAnimation(1, 0.5f, 1, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + + Animation translateAnimation = new TranslateAnimation(Animation.ABSOLUTE, lastOffsetX, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, lastOffsetY, + Animation.ABSOLUTE, 0); + + scaleAnimation.setInterpolator(new AnticipateOvershootInterpolator(1.5f)); + translateAnimation.setInterpolator(new DecelerateInterpolator()); + animation.addAnimation(scaleAnimation); + animation.addAnimation(translateAnimation); + animation.setDuration(ANIMATION_DURATION); + animation.setInterpolator(new AnticipateOvershootInterpolator(1.5f)); + + recordButtonFab.setVisibility(View.GONE); + recordButtonFab.clearAnimation(); + recordButtonFab.startAnimation(animation); + } + + private float getXOffset(float x) { + return ViewUtil.isLtr(recordButtonFab) ? -Math.max(0, this.startPositionX - x) + : Math.max(0, x - this.startPositionX); + } + + private float getYOffset(float y) { + return Math.min(0, y - this.startPositionY); + } + } + + private static class LockDropTarget { + + private final View lockDropTarget; + private final int dropTargetPosition; + + LockDropTarget(Context context, View lockDropTarget) { + this.lockDropTarget = lockDropTarget; + this.dropTargetPosition = context.getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target); + } + + void display() { + lockDropTarget.setScaleX(1); + lockDropTarget.setScaleY(1); + lockDropTarget.setAlpha(0); + lockDropTarget.setTranslationY(0); + lockDropTarget.setVisibility(VISIBLE); + lockDropTarget.animate() + .setStartDelay(ANIMATION_DURATION * 2) + .setDuration(ANIMATION_DURATION) + .setInterpolator(new DecelerateInterpolator()) + .translationY(dropTargetPosition) + .alpha(1) + .start(); + } + + void hide() { + lockDropTarget.animate() + .setStartDelay(0) + .setDuration(ANIMATION_DURATION) + .setInterpolator(new LinearInterpolator()) + .scaleX(0).scaleY(0) + .start(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java new file mode 100644 index 0000000000000000000000000000000000000000..c40cd8a22bd73b445a5ae8f8f69edda95afa71bc --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.net.Uri; +import android.text.Spannable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcMsg; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.util.MarkdownUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ResUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; + +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.VcardContact; + +public class QuoteView extends FrameLayout implements RecipientForeverObserver { + + private static final String TAG = QuoteView.class.getSimpleName(); + + private static final int MESSAGE_TYPE_PREVIEW = 0; + + private ViewGroup mainView; + private TextView authorView; + private TextView bodyView; + private ImageView quoteBarView; + private ImageView thumbnailView; + private View attachmentVideoOverlayView; + private ViewGroup attachmentContainerView; + private ImageView dismissView; + + private DcMsg quotedMsg; + private DcContact author; + private CharSequence body; + private SlideDeck attachments; + private int messageType; + private boolean hasSticker; + private boolean isEdit; + + public QuoteView(Context context) { + super(context); + initialize(null); + } + + public QuoteView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + public QuoteView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(attrs); + } + + public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.quote_view, this); + + this.mainView = findViewById(R.id.quote_main); + this.authorView = findViewById(R.id.quote_author); + this.bodyView = findViewById(R.id.quote_text); + this.quoteBarView = findViewById(R.id.quote_bar); + this.thumbnailView = findViewById(R.id.quote_thumbnail); + this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay); + this.attachmentContainerView = findViewById(R.id.quote_attachment_container); + this.dismissView = findViewById(R.id.quote_dismiss); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0); + messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0); + typedArray.recycle(); + + dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE); + if (messageType == MESSAGE_TYPE_PREVIEW) { + bodyView.setSingleLine(); + } else { + bodyView.setMaxLines(3); + } + } + + dismissView.setOnClickListener(view -> setVisibility(GONE)); + } + + public void setQuote(GlideRequests glideRequests, + DcMsg msg, + @Nullable Recipient author, + @Nullable CharSequence body, + @NonNull SlideDeck attachments, + boolean hasSticker, + boolean isEdit) + { + quotedMsg = msg; + this.author = author != null ? author.getDcContact() : null; + this.body = body; + this.attachments = attachments; + this.hasSticker = hasSticker; + this.isEdit = isEdit; + + if (hasSticker) { + this.setBackgroundResource(R.drawable.conversation_item_update_background); + bodyView.setTextColor(getResources().getColor(R.color.core_dark_05)); + } + setQuoteAuthor(author); + setQuoteText(body, attachments); + setQuoteAttachment(glideRequests, attachments); + } + + public void dismiss() { + this.author = null; + this.body = null; + + setVisibility(GONE); + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + setQuoteAuthor(recipient); + } + + private void setQuoteAuthor(@Nullable Recipient author) { + if (isEdit) { + authorView.setVisibility(VISIBLE); + authorView.setTextColor(getEditColor()); + quoteBarView.setBackgroundColor(getEditColor()); + authorView.setText(getContext().getString(R.string.edit_message)); + } else if (author == null) { + authorView.setVisibility(GONE); + quoteBarView.setBackgroundColor(getForwardedColor()); + } else if (quotedMsg.isForwarded()) { + DcContact contact = author.getDcContact(); + authorView.setVisibility(VISIBLE); + if (contact == null) { + authorView.setText(getContext().getString(R.string.forwarded_message)); + } else { + authorView.setText(getContext().getString(R.string.forwarded_by, quotedMsg.getSenderName(contact))); + } + authorView.setTextColor(getForwardedColor()); + quoteBarView.setBackgroundColor(getForwardedColor()); + } else { + DcContact contact = author.getDcContact(); + if (contact == null) { + authorView.setVisibility(GONE); + quoteBarView.setBackgroundColor(getForwardedColor()); + } else { + authorView.setVisibility(VISIBLE); + authorView.setText(quotedMsg.getSenderName(contact)); + if (hasSticker) { + authorView.setTextColor(getResources().getColor(R.color.core_dark_05)); + quoteBarView.setBackgroundColor(getResources().getColor(R.color.core_dark_05)); + } else { + authorView.setTextColor(Util.rgbToArgbColor(contact.getColor())); + quoteBarView.setBackgroundColor(Util.rgbToArgbColor(contact.getColor())); + } + } + } + } + + private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { + if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) { + bodyView.setVisibility(VISIBLE); + bodyView.setText((Spannable) MarkdownUtil.toMarkdown(getContext(), body == null ? "" : body.toString())); + } else { + bodyView.setVisibility(GONE); + } + } + + private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) { + List slides = slideDeck.getSlides(); + Slide slide = slides.isEmpty()? null : slides.get(0); + + attachmentVideoOverlayView.setVisibility(GONE); + + if (slide != null && slide.hasQuoteThumbnail()) { + thumbnailView.setVisibility(VISIBLE); + attachmentContainerView.setVisibility(GONE); + dismissView.setBackgroundResource(R.drawable.dismiss_background); + + if (slide.isWebxdcDocument()) { + try { + JSONObject info = quotedMsg.getWebxdcInfo(); + byte[] blob = quotedMsg.getWebxdcBlob(info.getString("icon")); + glideRequests.load(blob) + .centerCrop() + .override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size)) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .into(thumbnailView); + } catch (Exception e) { + Log.e(TAG, "failed to get webxdc icon", e); + thumbnailView.setVisibility(GONE); + } + } else if (slide.isVcard()) { + try { + VcardContact vcardContact = DcHelper.getRpc(getContext()).parseVcard(quotedMsg.getFile()).get(0); + Recipient recipient = new Recipient(getContext(), vcardContact); + glideRequests.load(recipient.getContactPhoto(getContext())) + .error(recipient.getFallbackAvatarDrawable(getContext(), false)) + .centerCrop() + .override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(thumbnailView); + } catch (RpcException e) { + Log.e(TAG, "failed to parse vCard", e); + thumbnailView.setVisibility(GONE); + } + } else { + Uri thumbnailUri = slide.getUri(); + if (slide.hasVideo()) { + attachmentVideoOverlayView.setVisibility(VISIBLE); + MediaUtil.createVideoThumbnailIfNeeded(getContext(), slide.getUri(), slide.getThumbnailUri(), null); + thumbnailUri = slide.getThumbnailUri(); + } + if (thumbnailUri != null) { + glideRequests.load(new DecryptableUri(thumbnailUri)) + .centerCrop() + .override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size)) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .into(thumbnailView); + } + } + } else if(slide != null && slide.hasAudio()) { + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(GONE); + } else if (slide != null && slide.hasDocument()) { + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(VISIBLE); + } else { + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(GONE); + } + + if (ThemeUtil.isDarkTheme(getContext())) { + dismissView.setBackgroundResource(R.drawable.circle_alpha); + } + } + + public CharSequence getBody() { + return body; + } + + public List getAttachments() { + return attachments.asAttachments(); + } + + public DcContact getDcContact() { + return author; + } + + public DcMsg getOriginalMsg() { + return quotedMsg; + } + + private int getForwardedColor() { + return getResources().getColor(hasSticker? R.color.core_dark_05 : R.color.unknown_sender); + } + + private int getEditColor() { + return ResUtil.getColor(getContext(), R.attr.colorAccent); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java new file mode 100644 index 0000000000000000000000000000000000000000..3468d89c6f7927847f82370c04a5a4a42c62c752 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.signature.MediaStoreSignature; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.database.loaders.RecentPhotosLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.LoaderCallbacks { + + @NonNull private final RecyclerView recyclerView; + @Nullable private OnItemClickedListener listener; + + public RecentPhotoViewRail(Context context) { + this(context, null); + } + + public RecentPhotoViewRail(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RecentPhotoViewRail(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.recent_photo_view, this); + + this.recyclerView = ViewUtil.findById(this, R.id.photo_list); + this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); + this.recyclerView.setItemAnimator(new DefaultItemAnimator()); + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.listener = listener; + + if (this.recyclerView.getAdapter() != null) { + ((RecentPhotoAdapter)this.recyclerView.getAdapter()).setListener(listener); + } + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new RecentPhotosLoader(getContext()); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor data) { + this.recyclerView.setAdapter(new RecentPhotoAdapter(getContext(), data, RecentPhotosLoader.BASE_URL, listener)); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); + } + + private static class RecentPhotoAdapter extends CursorRecyclerViewAdapter { + + @NonNull private final Uri baseUri; + @Nullable private OnItemClickedListener clickedListener; + + private RecentPhotoAdapter(@NonNull Context context, @NonNull Cursor cursor, @NonNull Uri baseUri, @Nullable OnItemClickedListener listener) { + super(context, cursor); + this.baseUri = baseUri; + this.clickedListener = listener; + } + + @Override + public RecentPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.recent_photo_view_item, parent, false); + + return new RecentPhotoViewHolder(itemView); + } + + @Override + public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { + viewHolder.imageView.setImageDrawable(null); + + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); + long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); + long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); + String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); + int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); + + final Uri uri = ContentUris.withAppendedId(RecentPhotosLoader.BASE_URL, rowId); + + Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); + + GlideApp.with(getContext().getApplicationContext()) + .load(uri) + .signature(signature) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(viewHolder.imageView); + + viewHolder.imageView.setOnClickListener(v -> { + if (clickedListener != null) clickedListener.onItemClicked(uri); + }); + + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.clickedListener = listener; + } + + static class RecentPhotoViewHolder extends RecyclerView.ViewHolder { + + final ImageView imageView; + + RecentPhotoViewHolder(View itemView) { + super(itemView); + + this.imageView = ViewUtil.findById(itemView, R.id.thumbnail); + } + } + } + + public interface OnItemClickedListener { + void onItemClicked(Uri uri); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java b/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java new file mode 100644 index 0000000000000000000000000000000000000000..20a88178a76b5d35eedef9da9ff42e5ff10f1c16 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; + +public class RemovableEditableMediaView extends FrameLayout { + + private final @NonNull ImageView remove; + private final @NonNull ImageView edit; + + private final int removeSize; + + private @Nullable View current; + + public RemovableEditableMediaView(Context context) { + this(context, null); + } + + public RemovableEditableMediaView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RemovableEditableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); + this.edit = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_edit_button, this, false); + + this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); + + this.remove.setVisibility(View.GONE); + this.edit.setVisibility(View.GONE); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.addView(remove); + this.addView(edit); + } + + public void display(@Nullable View view, boolean editable) { + edit.setVisibility(editable ? View.VISIBLE : View.GONE); + + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + + if (view != null) { + view.setPadding(view.getPaddingLeft(), removeSize / 2, removeSize / 2, view.getPaddingRight()); + edit.setPadding(0, 0, removeSize / 2, 0); + + view.setVisibility(View.VISIBLE); + remove.setVisibility(View.VISIBLE); + } else { + remove.setVisibility(View.GONE); + edit.setVisibility(View.GONE); + } + + current = view; + } + + @Nullable + public View getCurrent() { + return current; + } + + public void setRemoveClickListener(View.OnClickListener listener) { + this.remove.setOnClickListener(listener); + } + + public void setEditClickListener(View.OnClickListener listener) { + this.edit.setOnClickListener(listener); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ScaleStableImageView.java b/src/main/java/org/thoughtcrime/securesms/components/ScaleStableImageView.java new file mode 100644 index 0000000000000000000000000000000000000000..1fc3f006ec1936d05ef8282f256798f1c5b41dc4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ScaleStableImageView.java @@ -0,0 +1,183 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.Log; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Util; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + +import static android.content.res.Configuration.ORIENTATION_PORTRAIT; +import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; + +/** + * An image view that maintains the size of the drawable provided. + * That means: when you set an image drawable it will be scaled to fit the screen once. + * If you rotate the screen it will be rescaled to fit. + * If you crop the screen (e.g. because the soft keyboard is displayed) the image is cropped instead. + * + * @author Angelo Fuchs + */ +public class ScaleStableImageView + extends AppCompatImageView + implements KeyboardAwareLinearLayout.OnKeyboardShownListener, KeyboardAwareLinearLayout.OnKeyboardHiddenListener { + + private static final String TAG = ScaleStableImageView.class.getSimpleName(); + + private Drawable defaultDrawable; + private Drawable currentDrawable; + private final Map storedSizes = new HashMap<>(); + + public ScaleStableImageView(Context context) { + this(context, null); + } + + public ScaleStableImageView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ScaleStableImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setImageDrawable(@Nullable Drawable drawable) { + defaultDrawable = drawable; + storedSizes.clear(); + overrideDrawable(defaultDrawable); + } + + private void overrideDrawable(Drawable newDrawable) { + if (currentDrawable == newDrawable) return; + currentDrawable = newDrawable; + super.setImageDrawable(newDrawable); + } + + private int landscapeWidth = 0; + private int landscapeHeight = 0; + private int portraitWidth = 0; + private int portraitHeight = 0; + private boolean keyboardShown = false; + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + if (width == 0 || height == 0) return; + final String newKey = width + "x" + height; + int orientation = getResources().getConfiguration().orientation; + boolean portrait; + if (orientation == ORIENTATION_PORTRAIT) { + portrait = true; + } else if (orientation == ORIENTATION_LANDSCAPE) { + portrait = false; + } else { + Log.i(TAG, "orientation was: " + orientation); + return; // something fishy happened. + } + + if (!(defaultDrawable instanceof BitmapDrawable)) { + return; // need Bitmap for scaling and cropping. + } + + measureViewSize(width, height, oldWidth, oldHeight, portrait); + // if the image is already fit for the screen, just show it. + if (defaultDrawable.getIntrinsicWidth() == width && + defaultDrawable.getIntrinsicHeight() == height) { + overrideDrawable(defaultDrawable); + } + + // check if we have the new one already + if (storedSizes.containsKey(newKey)) { + super.setImageDrawable(storedSizes.get(newKey)); + return; + } + + if (keyboardShown) { + // don't scale; Crop. + Drawable large; + if (portrait) + large = storedSizes.get(portraitWidth + "x" + portraitHeight); + else + large = storedSizes.get(landscapeWidth + "x" + landscapeHeight); + if (large == null) return; // no baseline. can't work. + Bitmap original = ((BitmapDrawable) large).getBitmap(); + if (height <= original.getHeight() && width <= original.getWidth()) { + Bitmap cropped = Bitmap.createBitmap(original, 0, 0, width, height); + Drawable croppedDrawable = new BitmapDrawable(getResources(), cropped); + overrideDrawable(croppedDrawable); + } + } else { + Util.runOnBackground(() -> { + Bitmap bitmap = ((BitmapDrawable) defaultDrawable).getBitmap(); + Context context = getContext(); + try { + Bitmap scaledBitmap = GlideApp.with(context) + .asBitmap() + .load(bitmap) + .centerCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit(width, height) + .get(); + Drawable rescaled = new BitmapDrawable(getResources(), scaledBitmap); + storedSizes.put(newKey, rescaled); + Util.runOnMain(() -> overrideDrawable(rescaled)); + } catch (ExecutionException | InterruptedException ex) { + Log.e(TAG, "could not rescale background", ex); + // No background set. + } + }); + } + super.onSizeChanged(width, height, oldWidth, oldHeight); + } + + private void measureViewSize(int width, int height, int oldWidth, int oldHeight, boolean portrait) { + if (portraitWidth != 0 && portraitHeight != 0 && landscapeWidth != 0 && landscapeHeight != 0) + return; + + if (oldWidth == 0 && oldHeight == 0) { // screen just opened from inside the app + if (portrait) { // portrait + portraitHeight = height; + portraitWidth = width; + } else { // landscape + landscapeHeight = height; + landscapeWidth = width; + } + } else { + if (oldWidth == portraitWidth) { // was in portrait + if (!portrait) { // rotate to landscape + landscapeHeight = height; + landscapeWidth = width; + } + } else if (oldHeight == landscapeHeight) { + if (portrait) { + portraitHeight = height; + portraitWidth = width; + } + } + } + } + + @Override + public void onKeyboardHidden() { + keyboardShown = false; + Log.i(TAG, "Keyboard hidden"); + } + + @Override + public void onKeyboardShown() { + keyboardShown = true; + Log.i(TAG, "Keyboard shown"); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java new file mode 100644 index 0000000000000000000000000000000000000000..ca3fea5cb246b6e43ec3919304c8f22c2cfbb63a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java @@ -0,0 +1,179 @@ +package org.thoughtcrime.securesms.components; + + +import android.animation.Animator; +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.os.Build; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; + +public class SearchToolbar extends LinearLayout { + + private static final String TAG = SearchToolbar.class.getSimpleName(); + private float x, y; + private MenuItem searchItem; + private EditText searchText; + private SearchListener listener; + + public SearchToolbar(Context context) { + super(context); + initialize(); + } + + public SearchToolbar(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.search_toolbar, this); + setOrientation(VERTICAL); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar == null) { + Log.e(TAG, "SearchToolbar: No toolbar"); + return; + } + + Drawable drawable = getContext().getResources().getDrawable(R.drawable.ic_arrow_back_white_24dp); + drawable.mutate(); + drawable.setColorFilter(getContext().getResources().getColor(R.color.grey_700), PorterDuff.Mode.SRC_IN); + + toolbar.setNavigationIcon(drawable); + toolbar.inflateMenu(R.menu.conversation_list_search); + + this.searchItem = toolbar.getMenu().findItem(R.id.action_filter_search); + SearchView searchView = (SearchView) searchItem.getActionView(); + searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text); + searchView.setImeOptions(EditorInfo.IME_ACTION_DONE); + + searchView.setSubmitButtonEnabled(false); + + if (searchText != null) { + searchText.setHint(R.string.search); + searchText.setOnEditorActionListener((textView, actionId, keyEvent) -> { + if (EditorInfo.IME_ACTION_DONE == actionId) { + searchView.clearFocus(); + return true; + } + return false; + }); + } else { + searchView.setQueryHint(getResources().getString(R.string.search)); + } + + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + if (listener != null) listener.onSearchTextChange(query); + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + return onQueryTextSubmit(newText); + } + }); + + searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + hide(); + return true; + } + }); + + MenuItem searchUnread = toolbar.getMenu().findItem(R.id.search_unread); + searchUnread.setOnMenuItemClickListener(item -> { + String t = searchText.getText().toString(); + if (!t.contains("is:unread")) { + t += (t.isEmpty() ? "" : " ") + "is:unread "; + } + searchText.setText(t); + searchText.setSelection(t.length(), t.length()); + return true; + }); + + toolbar.setNavigationOnClickListener(v -> hide()); + } + + @MainThread + public void display(float x, float y) { + if (getVisibility() != View.VISIBLE) { + this.x = x; + this.y = y; + + searchItem.expandActionView(); + + Animator animator = ViewAnimationUtils.createCircularReveal(this, (int) x, (int) y, 0, getWidth()); + animator.setDuration(400); + + setVisibility(View.VISIBLE); + animator.start(); + } + } + + public void collapse() { + searchItem.collapseActionView(); + } + + @MainThread + private void hide() { + if (getVisibility() == View.VISIBLE) { + + + if (listener != null) listener.onSearchClosed(); + + Animator animator = ViewAnimationUtils.createCircularReveal(this, (int) x, (int) y, getWidth(), 0); + animator.setDuration(400); + animator.addListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(@NonNull Animator animation) { + setVisibility(View.INVISIBLE); + } + }); + animator.start(); + } + } + + public boolean isVisible() { + return getVisibility() == View.VISIBLE; + } + + @MainThread + public void setListener(SearchListener listener) { + this.listener = listener; + } + + public interface SearchListener { + void onSearchTextChange(String text); + void onSearchClosed(); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/SendButton.java b/src/main/java/org/thoughtcrime/securesms/components/SendButton.java new file mode 100644 index 0000000000000000000000000000000000000000..f7a44e13c965caab728646235093b9c3d0a32c2c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/SendButton.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.appcompat.widget.AppCompatImageButton; + +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.TransportOptions.OnTransportChangedListener; +import org.thoughtcrime.securesms.TransportOptionsPopup; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.guava.Optional; + +public class SendButton extends AppCompatImageButton + implements TransportOptions.OnTransportChangedListener, + TransportOptionsPopup.SelectedListener, + View.OnLongClickListener +{ + + private final TransportOptions transportOptions; + + private Optional transportOptionsPopup = Optional.absent(); + + public SendButton(Context context) { + super(context); + this.transportOptions = initializeTransportOptions(); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + public SendButton(Context context, AttributeSet attrs) { + super(context, attrs); + this.transportOptions = initializeTransportOptions(); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + public SendButton(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + this.transportOptions = initializeTransportOptions(); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + private TransportOptions initializeTransportOptions() { + TransportOptions transportOptions = new TransportOptions(getContext()); + transportOptions.addOnTransportChangedListener(this); + + setOnLongClickListener(this); + + return transportOptions; + } + + private TransportOptionsPopup getTransportOptionsPopup() { + if (!transportOptionsPopup.isPresent()) { + transportOptionsPopup = Optional.of(new TransportOptionsPopup(getContext(), this, this)); + } + return transportOptionsPopup.get(); + } + + public void addOnTransportChangedListener(OnTransportChangedListener listener) { + transportOptions.addOnTransportChangedListener(listener); + } + + public TransportOption getSelectedTransport() { + return transportOptions.getSelectedTransport(); + } + + public void resetAvailableTransports() { + transportOptions.reset(); + } + + public void setDefaultTransport(TransportOption.Type type) { + transportOptions.setDefaultTransport(type); + } + + @Override + public void onSelected(TransportOption option) { + transportOptions.setSelectedTransport(option); + getTransportOptionsPopup().dismiss(); + } + + @Override + public void onChange(TransportOption newTransport, boolean isManualSelection) { + setImageResource(newTransport.getDrawable()); + setContentDescription(newTransport.getDescription()); + } + + @Override + public boolean onLongClick(View v) { + if (transportOptions.getEnabledTransports().size() > 1) { + getTransportOptionsPopup().display(transportOptions.getEnabledTransports()); + return true; + } + + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java b/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java new file mode 100644 index 0000000000000000000000000000000000000000..32eb8505159258137e815a0f64e68f656464a093 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public class ShapeScrim extends View { + + private enum ShapeType { + CIRCLE, SQUARE + } + + private final Paint eraser; + private final ShapeType shape; + private final float radius; + + private Bitmap scrim; + private Canvas scrimCanvas; + + public ShapeScrim(Context context) { + this(context, null); + } + + public ShapeScrim(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + try (TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ShapeScrim, 0, 0)) { + String shapeName = typedArray.getString(R.styleable.ShapeScrim_shape); + + if ("square".equalsIgnoreCase(shapeName)) this.shape = ShapeType.SQUARE; + else if ("circle".equalsIgnoreCase(shapeName)) this.shape = ShapeType.CIRCLE; + else this.shape = ShapeType.SQUARE; + + this.radius = typedArray.getFloat(R.styleable.ShapeScrim_radius, 0.4f); + } + } else { + this.shape = ShapeType.SQUARE; + this.radius = 0.4f; + } + + this.eraser = new Paint(); + this.eraser.setColor(0xFFFFFFFF); + this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } + + @Override + public void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + int shortDimension = Math.min(getWidth(), getHeight()); + float drawRadius = shortDimension * radius; + + if (scrimCanvas == null) { + scrim = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); + scrimCanvas = new Canvas(scrim); + } + + scrim.eraseColor(Color.TRANSPARENT); + scrimCanvas.drawColor(Color.parseColor("#55BDBDBD")); + + if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser); + else drawSquare(scrimCanvas, drawRadius, eraser); + + canvas.drawBitmap(scrim, 0, 0, null); + } + + @Override + public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldHeight, oldHeight); + + if (width != oldWidth || height != oldHeight) { + scrim = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + scrimCanvas = new Canvas(scrim); + } + } + + private void drawCircle(Canvas canvas, float radius, Paint eraser) { + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, eraser); + } + + private void drawSquare(Canvas canvas, float radius, Paint eraser) { + float left = (getWidth() / 2 ) - radius; + float top = (getHeight() / 2) - radius; + float right = left + (radius * 2); + float bottom = top + (radius * 2); + + RectF square = new RectF(left, top, right, bottom); + + canvas.drawRoundRect(square, 25, 25, eraser); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java b/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java new file mode 100644 index 0000000000000000000000000000000000000000..9517f8ea1c5de5fd29519b49a29865a4e4257db3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.R; + +public class SquareFrameLayout extends FrameLayout { + + private final boolean squareHeight; + + public SquareFrameLayout(Context context) { + this(context, null); + } + + public SquareFrameLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SquareFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + try (TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SquareFrameLayout, 0, 0)) { + this.squareHeight = typedArray.getBoolean(R.styleable.SquareFrameLayout_square_height, false); + } + } else { + this.squareHeight = false; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (squareHeight) super.onMeasure(heightMeasureSpec, heightMeasureSpec); + else super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java b/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java new file mode 100644 index 0000000000000000000000000000000000000000..c1e3e7b06828c0d4600695be0f1db5c408b8c194 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; + +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import org.thoughtcrime.securesms.R; + +public class SwitchPreferenceCompat extends CheckBoxPreference { + + private Preference.OnPreferenceClickListener listener; + + public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setLayoutRes(); + } + + public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setLayoutRes(); + } + + public SwitchPreferenceCompat(Context context, AttributeSet attrs) { + super(context, attrs); + setLayoutRes(); + } + + public SwitchPreferenceCompat(Context context) { + super(context); + setLayoutRes(); + } + + private void setLayoutRes() { + setWidgetLayoutResource(R.layout.switch_compat_preference); + } + + @Override + public void setOnPreferenceClickListener(Preference.OnPreferenceClickListener listener) { + this.listener = listener; + } + + @Override + protected void onClick() { + if (listener == null || !listener.onPreferenceClick(this)) { + super.onClick(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java new file mode 100644 index 0000000000000000000000000000000000000000..f57946ccf108adbec698129841df3f092a42c631 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -0,0 +1,320 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Locale; + +import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; + +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + +public class ThumbnailView extends FrameLayout { + + private static final String TAG = ThumbnailView.class.getSimpleName(); + private static final int WIDTH = 0; + private static final int HEIGHT = 1; + private static final int MIN_WIDTH = 0; + private static final int MAX_WIDTH = 1; + private static final int MIN_HEIGHT = 2; + private static final int MAX_HEIGHT = 3; + + private final ImageView image; + private final View playOverlay; + private OnClickListener parentClickListener; + + private final int[] dimens = new int[2]; + private final int[] bounds = new int[4]; + private final int[] measureDimens = new int[2]; + + private SlideClickListener thumbnailClickListener = null; + private Slide slide = null; + + public ThumbnailView(Context context) { + this(context, null); + } + + public ThumbnailView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + inflate(context, R.layout.thumbnail_view, this); + + this.image = findViewById(R.id.thumbnail_image); + this.playOverlay = findViewById(R.id.play_overlay); + super.setOnClickListener(new ThumbnailClickDispatcher()); + + if (attrs != null) { + try (TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)) { + bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0); + bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0); + bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); + bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); + // int radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.gallery_thumbnail_radius)); + } + } + + } + + public String getDescription() { + if (slide != null && slide.hasPlayOverlay()) { + return getContext().getString(R.string.video); + } else { + return getContext().getString(R.string.image); + } + } + + @Override + protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) { + fillTargetDimensions(measureDimens, dimens, bounds); + if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) { + super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec); + return; + } + + int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight(); + int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom(); + + super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)); + } + + @SuppressWarnings("SuspiciousNameCombination") + private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) { + int dimensFilledCount = getNonZeroCount(dimens); + int boundsFilledCount = getNonZeroCount(bounds); + + if (dimensFilledCount == 0 || boundsFilledCount == 0) { + targetDimens[WIDTH] = 0; + targetDimens[HEIGHT] = 0; + return; + } + + double naturalWidth = dimens[WIDTH]; + double naturalHeight = dimens[HEIGHT]; + + int minWidth = bounds[MIN_WIDTH]; + int maxWidth = bounds[MAX_WIDTH]; + int minHeight = bounds[MIN_HEIGHT]; + int maxHeight = bounds[MAX_HEIGHT]; + + if (dimensFilledCount > 0 && dimensFilledCount < dimens.length) { + throw new IllegalStateException(String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %f x %f", + naturalWidth, naturalHeight)); + } + if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) { + throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]", + minWidth, maxWidth, minHeight, maxHeight)); + } + + double measuredWidth = naturalWidth; + double measuredHeight = naturalHeight; + + boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth; + boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight; + + if (!widthInBounds || !heightInBounds) { + double minWidthRatio = naturalWidth / minWidth; + double maxWidthRatio = naturalWidth / maxWidth; + double minHeightRatio = naturalHeight / minHeight; + double maxHeightRatio = naturalHeight / maxHeight; + + if (maxWidthRatio > 1 || maxHeightRatio > 1) { + if (maxWidthRatio >= maxHeightRatio) { + measuredWidth /= maxWidthRatio; + measuredHeight /= maxWidthRatio; + } else { + measuredWidth /= maxHeightRatio; + measuredHeight /= maxHeightRatio; + } + + measuredWidth = Math.max(measuredWidth, minWidth); + measuredHeight = Math.max(measuredHeight, minHeight); + + } else if (minWidthRatio < 1 || minHeightRatio < 1) { + if (minWidthRatio <= minHeightRatio) { + measuredWidth /= minWidthRatio; + measuredHeight /= minWidthRatio; + } else { + measuredWidth /= minHeightRatio; + measuredHeight /= minHeightRatio; + } + + measuredWidth = Math.min(measuredWidth, maxWidth); + measuredHeight = Math.min(measuredHeight, maxHeight); + } + } + + targetDimens[WIDTH] = (int) measuredWidth; + targetDimens[HEIGHT] = (int) measuredHeight; + } + + private int getNonZeroCount(int[] vals) { + int count = 0; + for (int val : vals) { + if (val > 0) { + count++; + } + } + return count; + } + + @Override + public void setOnClickListener(OnClickListener l) { + parentClickListener = l; + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + } + + @UiThread + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide) + { + return setImageResource(glideRequests, slide, 0, 0); + } + + @SuppressLint("StaticFieldLeak") + @UiThread + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, + int naturalWidth, int naturalHeight) + { + if (slide.hasPlayOverlay()) { + this.playOverlay.setVisibility(View.VISIBLE); + } + else { + this.playOverlay.setVisibility(View.GONE); + } + + if (Util.equals(slide, this.slide)) { + Log.w(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri()); + return new SettableFuture<>(false); + } + + if (this.slide != null && this.slide.getFastPreflightId() != null && + this.slide.getFastPreflightId().equals(slide.getFastPreflightId())) + { + Log.w(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId()); + this.slide = slide; + return new SettableFuture<>(false); + } + + Log.w(TAG, "loading part with id " + slide.asAttachment().getDataUri() + + ", progress " + slide.getTransferState() + ", fast preflight id: " + + slide.asAttachment().getFastPreflightId()); + + this.slide = slide; + + dimens[WIDTH] = naturalWidth; + dimens[HEIGHT] = naturalHeight; + invalidate(); + + SettableFuture result = new SettableFuture<>(); + + if (slide.getThumbnailUri() != null) + { + if(slide.hasVideo()) + { + Uri dataUri = slide.getUri(); + Uri thumbnailUri = slide.getThumbnailUri(); + ImageView img = findViewById(R.id.thumbnail_image); + Context context = getContext(); + new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + return MediaUtil.createVideoThumbnailIfNeeded(context, dataUri, thumbnailUri, null); + } + @Override + protected void onPostExecute(Boolean success) { + GlideRequest request = glideRequests.load(new DecryptableUri(thumbnailUri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(withCrossFade()); + request.into(new GlideDrawableListeningTarget(img, result)); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + if(slide.hasSticker()) + { + GlideRequest request = glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) + .diskCacheStrategy(DiskCacheStrategy.NONE); + request.into(new GlideDrawableListeningTarget(image, result)); + } + else + { + GlideRequest request = glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(withCrossFade()); + request.into(new GlideDrawableListeningTarget(image, result)); + } + } + else + { + glideRequests.clear(image); + result.set(false); + } + + return result; + } + + public void setThumbnailClickListener(SlideClickListener listener) { + this.thumbnailClickListener = listener; + } + + public void clear(GlideRequests glideRequests) { + glideRequests.clear(image); + + slide = null; + } + + public void setScaleType(@NonNull ImageView.ScaleType scale) { + image.setScaleType(scale); + } + + private class ThumbnailClickDispatcher implements View.OnClickListener { + @Override + public void onClick(View view) { + if (thumbnailClickListener != null && + slide != null && + slide.asAttachment().getDataUri() != null && + slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) + { + thumbnailClickListener.onClick(view, slide); + } else if (parentClickListener != null) { + parentClickListener.onClick(view); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/VcardView.java b/src/main/java/org/thoughtcrime/securesms/components/VcardView.java new file mode 100644 index 0000000000000000000000000000000000000000..a5b635bcdb6e9b083a1e70eeaf286a8c67d8b230 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/VcardView.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.VcardSlide; +import org.thoughtcrime.securesms.recipients.Recipient; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.VcardContact; + +public class VcardView extends FrameLayout { + private static final String TAG = VcardView.class.getSimpleName(); + + private final @NonNull AvatarView avatar; + private final @NonNull TextView name; + + private @Nullable SlideClickListener viewListener; + private @Nullable VcardSlide slide; + + public VcardView(Context context) { + this(context, null); + } + + public VcardView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public VcardView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + inflate(context, R.layout.vcard_view, this); + + this.avatar = findViewById(R.id.avatar); + this.name = findViewById(R.id.name); + + setOnClickListener(v -> { + if (viewListener != null && slide != null) { + viewListener.onClick(v, slide); + } + }); + } + + public void setVcardClickListener(@Nullable SlideClickListener listener) { + this.viewListener = listener; + } + + public void setVcard(@NonNull GlideRequests glideRequests, final @NonNull VcardSlide slide, final @NonNull Rpc rpc) { + try { + VcardContact vcardContact = rpc.parseVcard(slide.asAttachment().getRealPath(getContext())).get(0); + name.setText(vcardContact.displayName); + avatar.setAvatar(glideRequests, new Recipient(getContext(), vcardContact), false); + this.slide = slide; + } catch (RpcException e) { + Log.e(TAG, "failed to parse vCard", e); + } + } + + public String getDescription() { + return name.getText().toString(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/WebxdcView.java b/src/main/java/org/thoughtcrime/securesms/components/WebxdcView.java new file mode 100644 index 0000000000000000000000000000000000000000..84ced7bbe3b98fc0f81a93011944e5088a608b42 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/WebxdcView.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.content.res.TypedArray; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.b44t.messenger.DcMsg; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.ByteArrayInputStream; + +public class WebxdcView extends FrameLayout { + + private final @NonNull AppCompatImageView icon; + private final @NonNull TextView appName; + private final @NonNull TextView appSubtitle; + + private @Nullable SlideClickListener viewListener; + + public WebxdcView(@NonNull Context context) { + this(context, null); + } + + public WebxdcView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WebxdcView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + + boolean compact; + try (TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.WebxdcView, 0, 0)) { + compact = a.getBoolean(R.styleable.WebxdcView_compact, false); + } + if (compact) { + inflate(context, R.layout.webxdc_compact_view, this); + } else { + inflate(context, R.layout.webxdc_view, this); + } + + this.icon = findViewById(R.id.webxdc_icon); + this.appName = findViewById(R.id.webxdc_app_name); + this.appSubtitle = findViewById(R.id.webxdc_subtitle); + } + + public void setWebxdcClickListener(@Nullable SlideClickListener listener) { + this.viewListener = listener; + } + + public void setWebxdc(final @NonNull DcMsg dcMsg, String defaultSummary) + { + JSONObject info = dcMsg.getWebxdcInfo(); + setOnClickListener(new OpenClickedListener(getContext(), dcMsg)); + + // icon + byte[] blob = dcMsg.getWebxdcBlob(JsonUtils.optString(info, "icon")); + if (blob != null) { + ByteArrayInputStream is = new ByteArrayInputStream(blob); + Drawable drawable = Drawable.createFromStream(is, "icon"); + icon.setImageDrawable(drawable); + } + + // name + String docName = JsonUtils.optString(info, "document"); + String xdcName = JsonUtils.optString(info, "name"); + appName.setText(docName.isEmpty() ? xdcName : (docName + " – " + xdcName)); + + // subtitle + String summary = info.optString("summary"); + if (summary.isEmpty()) { + summary = defaultSummary; + } + if (summary.isEmpty()) { + appSubtitle.setVisibility(View.GONE); + } else { + appSubtitle.setVisibility(View.VISIBLE); + appSubtitle.setText(summary); + } + } + + public String getDescription() { + String type = getContext().getString( R.string.webxdc_app); + String desc = type; + desc += "\n" + appName.getText(); + if (appSubtitle.getText() != null && !appSubtitle.getText().toString().isEmpty() && !appSubtitle.getText().toString().equals(type)) { + desc += "\n" + appSubtitle.getText(); + } + return desc; + } + + private class OpenClickedListener implements View.OnClickListener { + private final @NonNull DocumentSlide slide; + + private OpenClickedListener(Context context, @NonNull DcMsg dcMsg) { + this.slide = new DocumentSlide(context, dcMsg); + } + + @Override + public void onClick(View v) { + if (viewListener != null) { + viewListener.onClick(v, slide); + } + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java b/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java new file mode 100644 index 0000000000000000000000000000000000000000..3e4b31e718bdb2653b826ea1fa666a74a1fed125 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Pair; +import android.view.View; +import android.widget.FrameLayout; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.Target; +import com.davemorrissey.labs.subscaleview.ImageSource; +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView; +import com.davemorrissey.labs.subscaleview.decoder.DecoderFactory; +import com.github.chrisbanes.photoview.PhotoView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.subsampling.AttachmentBitmapDecoder; +import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; +import java.io.InputStream; + + +public class ZoomingImageView extends FrameLayout { + + private static final String TAG = ZoomingImageView.class.getName(); + + private final PhotoView photoView; + private final SubsamplingScaleImageView subsamplingImageView; + + public ZoomingImageView(Context context) { + this(context, null); + } + + public ZoomingImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.zooming_image_view, this); + + this.photoView = findViewById(R.id.image_view); + this.subsamplingImageView = findViewById(R.id.subsampling_image_view); + + this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF); + } + + @SuppressLint("StaticFieldLeak") + public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType) + { + final Context context = getContext(); + final int maxTextureSize = BitmapUtil.getMaxTextureSize(); + + Log.w(TAG, "Max texture size: " + maxTextureSize); + + new AsyncTask>() { + @Override + protected @Nullable Pair doInBackground(Void... params) { + if (MediaUtil.isGif(contentType)) return null; + + try { + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + return BitmapUtil.getDimensions(inputStream); + } catch (IOException | BitmapDecodingException e) { + Log.w(TAG, e); + return null; + } + } + + protected void onPostExecute(@Nullable Pair dimensions) { + Log.w(TAG, "Dimensions: " + (dimensions == null ? "(null)" : dimensions.first + ", " + dimensions.second)); + + if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { + Log.w(TAG, "Loading in standard image view..."); + setImageViewUri(glideRequests, uri); + } else { + Log.w(TAG, "Loading in subsampling image view..."); + setSubsamplingImageViewUri(uri); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { + photoView.setVisibility(View.VISIBLE); + subsamplingImageView.setVisibility(View.GONE); + + glideRequests.load(new DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontTransform() + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .into(photoView); + } + + private void setSubsamplingImageViewUri(@NonNull Uri uri) { + subsamplingImageView.setBitmapDecoderFactory(new AttachmentBitmapDecoderFactory()); + subsamplingImageView.setRegionDecoderFactory(new AttachmentRegionDecoderFactory()); + + subsamplingImageView.setVisibility(View.VISIBLE); + photoView.setVisibility(View.GONE); + + subsamplingImageView.setImage(ImageSource.uri(uri)); + } + + public void cleanup() { + photoView.setImageDrawable(null); + subsamplingImageView.recycle(); + } + + private static class AttachmentBitmapDecoderFactory implements DecoderFactory { + @Override + public AttachmentBitmapDecoder make() throws IllegalAccessException, InstantiationException { + return new AttachmentBitmapDecoder(); + } + } + + private static class AttachmentRegionDecoderFactory implements DecoderFactory { + @Override + public AttachmentRegionDecoder make() throws IllegalAccessException, InstantiationException { + return new AttachmentRegionDecoder(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/emoji/AutoScaledEmojiTextView.java b/src/main/java/org/thoughtcrime/securesms/components/emoji/AutoScaledEmojiTextView.java new file mode 100644 index 0000000000000000000000000000000000000000..8ab53699d211e6d90b4e0b1221b71cf3216c3c85 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/emoji/AutoScaledEmojiTextView.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.util.TypedValue; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; + +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.text.BreakIterator; +import java.util.Locale; +import java.util.regex.Pattern; + + +public class AutoScaledEmojiTextView extends AppCompatTextView { + + /* + source: https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AEmoji%3DYes%3A%5D&g=&i= + with spaces, *, # and 0-9 removed and the corresponding emojis added explicitly + to avoid matching normal text with such characters + */ + private static final Pattern emojiRegex = Pattern.compile("([🏻-🏿😀😃😄😁😆😅🤣😂🙂🙃🫠😉😊😇🥰😍🤩😘😗☺😚😙🥲😋😛😜🤪😝🤑🤗🤭🫢🫣🤫🤔🫡🤐🤨😐😑😶🫥😏😒🙄😬🤥🫨😌😔😪🤤😴😷🤒🤕🤢🤮🤧🥵🥶🥴😵🤯🤠🥳🥸😎🤓🧐😕🫤😟🙁☹😮😯😲😳🥺🥹😦-😨😰😥😢😭😱😖😣😞😓😩😫🥱😤😡😠🤬😈👿💀☠💩🤡👹-👻👽👾🤖😺😸😹😻-😽🙀😿😾🙈-🙊💌💘💝💖💗💓💞💕💟❣💔❤🩷🧡💛💚💙🩵💜🤎🖤🩶🤍💋💯💢💥💫💦💨🕳💬🗨🗯💭💤👋🤚🖐✋🖖🫱-🫴🫷🫸👌🤌🤏✌🤞🫰🤟🤘🤙👈👉👆🖕👇☝🫵👍👎✊👊🤛🤜👏🙌🫶👐🤲🤝🙏✍💅🤳💪🦾🦿🦵🦶👂🦻👃🧠🫀🫁🦷🦴👀👁👅👄🫦👶🧒👦👧🧑👱👨🧔👩🧓👴👵🙍🙎🙅🙆💁🙋🧏🙇🤦🤷👮🕵💂🥷👷🫅🤴👸👳👲🧕🤵👰🤰🫃🫄🤱👼🎅🤶🦸🦹🧙-🧟🧌💆💇🚶🧍🧎🏃💃🕺🕴👯🧖🧗🤺🏇⛷🏂🏌🏄🚣🏊⛹🏋🚴🚵🤸🤼-🤾🤹🧘🛀🛌👭👫👬💏💑🗣👤👥🫂👪👣🦰🦱🦳🦲🐵🐒🦍🦧🐶🐕🦮🐩🐺🦊🦝🐱🐈🦁🐯🐅🐆🐴🫎🫏🐎🦄🦓🦌🦬🐮🐂-🐄🐷🐖🐗🐽🐏🐑🐐🐪🐫🦙🦒🐘🦣🦏🦛🐭🐁🐀🐹🐰🐇🐿🦫🦔🦇🐻🐨🐼🦥🦦🦨🦘🦡🐾🦃🐔🐓🐣-🐧🕊🦅🦆🦢🦉🦤🪶🦩🦚🦜🪽🪿🐸🐊🐢🦎🐍🐲🐉🦕🦖🐳🐋🐬🦭🐟-🐡🦈🐙🐚🪸🪼🐌🦋🐛-🐝🪲🐞🦗🪳🕷🕸🦂🦟🪰🪱🦠💐🌸💮🪷🏵🌹🥀🌺-🌼🌷🪻🌱🪴🌲-🌵🌾🌿☘🍀-🍃🪹🪺🍄🍇-🍍🥭🍎-🍓🫐🥝🍅🫒🥥🥑🍆🥔🥕🌽🌶🫑🥒🥬🥦🧄🧅🥜🫘🌰🫚🫛🍞🥐🥖🫓🥨🥯🥞🧇🧀🍖🍗🥩🥓🍔🍟🍕🌭🥪🌮🌯🫔🥙🧆🥚🍳🥘🍲🫕🥣🥗🍿🧈🧂🥫🍱🍘-🍝🍠🍢-🍥🥮🍡🥟-🥡🦀🦞🦐🦑🦪🍦-🍪🎂🍰🧁🥧🍫-🍯🍼🥛☕🫖🍵🍶🍾🍷-🍻🥂🥃🫗🥤🧋🧃🧉🧊🥢🍽🍴🥄🔪🫙🏺🌍-🌐🗺🗾🧭🏔⛰🌋🗻🏕🏖🏜-🏟🏛🏗🧱🪨🪵🛖🏘🏚🏠-🏦🏨-🏭🏯🏰💒🗼🗽⛪🕌🛕🕍⛩🕋⛲⛺🌁🌃🏙🌄-🌇🌉♨🎠🛝🎡🎢💈🎪🚂-🚊🚝🚞🚋-🚎🚐-🚙🛻🚚-🚜🏎🏍🛵🦽🦼🛺🚲🛴🛹🛼🚏🛣🛤🛢⛽🛞🚨🚥🚦🛑🚧⚓🛟⛵🛶🚤🛳⛴🛥🚢✈🛩🛫🛬🪂💺🚁🚟-🚡🛰🚀🛸🛎🧳⌛⏳⌚⏰-⏲🕰🕛🕧🕐🕜🕑🕝🕒🕞🕓🕟🕔🕠🕕🕡🕖🕢🕗🕣🕘🕤🕙🕥🕚🕦🌑-🌜🌡☀🌝🌞🪐⭐🌟🌠🌌☁⛅⛈🌤-🌬🌀🌈🌂☂☔⛱⚡❄☃⛄☄🔥💧🌊🎃🎄🎆🎇🧨✨🎈-🎋🎍-🎑🧧🎀🎁🎗🎟🎫🎖🏆🏅🥇-🥉⚽⚾🥎🏀🏐🏈🏉🎾🥏🎳🏏🏑🏒🥍🏓🏸🥊🥋🥅⛳⛸🎣🤿🎽🎿🛷🥌🎯🪀🪁🔫🎱🔮🪄🎮🕹🎰🎲🧩🧸🪅🪩🪆♠♥♦♣♟🃏🀄🎴🎭🖼🎨🧵🪡🧶🪢👓🕶🥽🥼🦺👔-👖🧣-🧦👗👘🥻🩱-🩳👙👚🪭👛-👝🛍🎒🩴👞👟🥾🥿👠👡🩰👢🪮👑👒🎩🎓🧢🪖⛑📿💄💍💎🔇-🔊📢📣📯🔔🔕🎼🎵🎶🎙-🎛🎤🎧📻🎷🪗🎸-🎻🪕🥁🪘🪇🪈📱📲☎📞-📠🔋🪫🔌💻🖥🖨⌨🖱🖲💽-📀🧮🎥🎞📽🎬📺📷-📹📼🔍🔎🕯💡🔦🏮🪔📔-📚📓📒📃📜📄📰🗞📑🔖🏷💰🪙💴-💸💳🧾💹✉📧-📩📤-📦📫📪📬-📮🗳✏✒🖋🖊🖌🖍📝💼📁📂🗂📅📆🗒🗓📇-📎🖇📏📐✂🗃🗄🗑🔒🔓🔏-🔑🗝🔨🪓⛏⚒🛠🗡⚔💣🪃🏹🛡🪚🔧🪛🔩⚙🗜⚖🦯🔗⛓🪝🧰🧲🪜⚗🧪-🧬🔬🔭📡💉🩸💊🩹🩼🩺🩻🚪🛗🪞🪟🛏🛋🪑🚽🪠🚿🛁🪤🪒🧴🧷🧹-🧻🪣🧼🫧🪥🧽🧯🛒🚬⚰🪦⚱🧿🪬🗿🪧🪪🏧🚮🚰♿🚹-🚼🚾🛂-🛅⚠🚸⛔🚫🚳🚭🚯🚱🚷📵🔞☢☣⬆↗➡↘⬇↙⬅↖↕↔↩↪⤴⤵🔃🔄🔙-🔝🛐⚛🕉✡☸☯✝☦☪☮🕎🔯🪯♈-♓⛎🔀-🔂▶⏩⏭⏯◀⏪⏮🔼⏫🔽⏬⏸-⏺⏏🎦🔅🔆📶🛜📳📴♀♂⚧✖➕-➗🟰♾‼⁉❓-❕❗〰💱💲⚕♻⚜🔱📛🔰⭕✅☑✔❌❎➰➿〽✳✴❇©®™🔟-🔤🅰🆎🅱🆑-🆓ℹ🆔Ⓜ🆕🆖🅾🆗🅿🆘-🆚🈁🈂🈷🈶🈯🉐🈹🈚🈲🉑🈸🈴🈳㊗㊙🈺🈵🔴🟠-🟢🔵🟣🟤⚫⚪🟥🟧-🟩🟦🟪🟫⬛⬜◼◻◾◽▪▫🔶-🔻💠🔘🔳🔲🏁🚩🎌🏴🏳🇦-🇿\uD83E\uDD89\uD83E\uDD8F\uD83E\uDDBE\uD83E\uDDC6\uD83E\uddcd\uD83E\udddf\uD83E\ude99]|#️⃣|\\*️⃣|0️⃣|1️⃣|2️⃣|3️⃣|4️⃣|5️⃣|6️⃣|7️⃣|8️⃣|9️⃣)+.*"); + private float originalFontSize; + + public AutoScaledEmojiTextView(Context context) { + this(context, null); + } + + public AutoScaledEmojiTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AutoScaledEmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + try (TypedArray typedArray = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize})) { + originalFontSize = ViewUtil.pxToSp(context, typedArray.getDimensionPixelSize(0, 0)); + if (originalFontSize == 0) { + originalFontSize = 16f; + } + } + } + + @Override + public void setText(@Nullable CharSequence text, BufferType type) { + float scale = text != null ? getTextScale(text.toString()) : 1; + super.setTextSize(TypedValue.COMPLEX_UNIT_SP, originalFontSize * scale); + super.setText(text, type); + } + + @Override + public void setTextSize(float size) { + setTextSize(TypedValue.COMPLEX_UNIT_SP, size); + } + + @Override + public void setTextSize(int unit, float size) { + if (unit == TypedValue.COMPLEX_UNIT_SP) { + originalFontSize = size; + } else { + float pxSize = TypedValue.applyDimension(unit, size, getResources().getDisplayMetrics()); + float spSize = ViewUtil.pxToSp(getContext(), (int) pxSize); + if (spSize > 0) { + originalFontSize = spSize; + } + } + super.setTextSize(unit, size); + } + + private float getTextScale(String text) { + if (text.length() > 21 || text.isEmpty() || Character.isLetter(text.charAt(0))) { + return 1; + } + int emojiCount = countEmojis(text, 8); + if (emojiCount <= 0) { + return 1; + } + + float scale = 1.25f; + if (emojiCount <= 6) scale += 0.25f; + if (emojiCount <= 4) scale += 0.25f; + if (emojiCount <= 2) scale += 0.25f; + return scale; + } + + /** + * Returns the number of emojis if there are only emojis AND there are no more than `max` emojis, + * or -1 otherwise. + */ + public static int countEmojis(String text, int max) { + BreakIterator graphemeIterator = BreakIterator.getCharacterInstance(Locale.getDefault()); + + graphemeIterator.setText(text); + + int graphemeCount = 0; + + // Iterate over the text and count graphemes + int start = graphemeIterator.first(); + for (int end = graphemeIterator.next(); end != BreakIterator.DONE; start = end, end = graphemeIterator.next()) { + String grapheme = text.substring(start, end); + if (!emojiRegex.matcher(grapheme).matches()) return -1; + if (++graphemeCount > max) return -1; + } + + return graphemeCount; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java new file mode 100644 index 0000000000000000000000000000000000000000..8517966871b1109b44de18a161c23f6dd7bb50bd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageButton; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ResUtil; + +public class EmojiToggle extends AppCompatImageButton { + + private Drawable emojiToggle; +// private Drawable stickerToggle; + + private Drawable mediaToggle; + private Drawable imeToggle; + + + public EmojiToggle(Context context) { + super(context); + initialize(); + } + + public EmojiToggle(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public EmojiToggle(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(); + } + + public void setToMedia() { + setImageDrawable(mediaToggle); + } + + public void setToIme() { + setImageDrawable(imeToggle); + } + + private void initialize() { + this.emojiToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_emoji_toggle); +// this.stickerToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_sticker_toggle); + this.imeToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_keyboard_toggle); + this.mediaToggle = emojiToggle; + + setToMedia(); + } + + public void setStickerMode(boolean stickerMode) { + this.mediaToggle = /*stickerMode ? stickerToggle :*/ emojiToggle; + + if (getDrawable() != imeToggle) { + setToMedia(); + } + } + + public boolean isStickerMode() { + //return this.mediaToggle == stickerToggle; + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java new file mode 100644 index 0000000000000000000000000000000000000000..6bc3a8fd7d0c811d126202be464c68a0854bb8a1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.emoji2.emojipicker.EmojiPickerView; +import androidx.emoji2.emojipicker.EmojiViewItem; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.InputAwareLayout.InputView; + +public class MediaKeyboard extends FrameLayout implements InputView, Consumer { + + private static final String TAG = MediaKeyboard.class.getSimpleName(); + + @Nullable private MediaKeyboardListener keyboardListener; + private EmojiPickerView emojiPicker; + + public MediaKeyboard(@NonNull Context context) { + super(context); + } + + public MediaKeyboard(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public void setKeyboardListener(@Nullable MediaKeyboardListener listener) { + this.keyboardListener = listener; + } + + @Override + public boolean isShowing() { + return getVisibility() == VISIBLE; + } + + @Override + public void show(int height, boolean immediate) { + ViewGroup.LayoutParams params = getLayoutParams(); + params.height = height; + Log.i(TAG, "showing emoji drawer with height " + params.height); + setLayoutParams(params); + + show(); + } + + public void show() { + if (emojiPicker == null) { + emojiPicker = findViewById(R.id.emoji_picker); + emojiPicker.setOnEmojiPickedListener(this); + } + setVisibility(VISIBLE); + if (keyboardListener != null) keyboardListener.onShown(); + } + + @Override + public void hide(boolean immediate) { + setVisibility(GONE); + if (keyboardListener != null) keyboardListener.onHidden(); + Log.i(TAG, "hide()"); + } + + @Override + public void accept(EmojiViewItem emojiViewItem) { + if (keyboardListener != null) keyboardListener.onEmojiPicked(emojiViewItem.getEmoji()); + } + + public interface MediaKeyboardListener { + void onShown(); + void onHidden(); + void onEmojiPicked(String emoji); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java b/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java new file mode 100644 index 0000000000000000000000000000000000000000..4fc116565fee29024e5138e4d022bafb62b2f37e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.components.recyclerview; + + +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView; + +public class DeleteItemAnimator extends DefaultItemAnimator { + + public DeleteItemAnimator() { + setSupportsChangeAnimations(false); + } + + @Override + public boolean animateAdd(RecyclerView.ViewHolder viewHolder) { + dispatchAddFinished(viewHolder); + return false; + } + + @Override + public boolean animateMove(RecyclerView.ViewHolder viewHolder, int fromX, int fromY, int toX, int toY) { + dispatchMoveFinished(viewHolder); + return false; + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java b/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java new file mode 100644 index 0000000000000000000000000000000000000000..9a0360d3ac171f16c2233e2bc14dd6d5c1c74563 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.components.registration; + + +import android.animation.Animator; +import android.content.Context; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; + +public class PulsingFloatingActionButton extends FloatingActionButton { + + private boolean pulsing; + + public PulsingFloatingActionButton(Context context) { + super(context); + } + + public PulsingFloatingActionButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public PulsingFloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void startPulse(long periodMillis) { + if (!pulsing) { + pulsing = true; + pulse(periodMillis); + } + } + + public void stopPulse() { + pulsing = false; + } + + private void pulse(long periodMillis) { + if (!pulsing) return; + + this.animate().scaleX(1.2f).scaleY(1.2f).setDuration(150).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(@NonNull Animator animation) { + clearAnimation(); + animate().scaleX(1.0f).scaleY(1.0f).setDuration(150).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(@NonNull Animator animation) { + PulsingFloatingActionButton.this.postDelayed(() -> pulse(periodMillis), periodMillis); + } + }).start(); + } + }).start(); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java b/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java new file mode 100644 index 0000000000000000000000000000000000000000..611dc8a6b57930baade38da48b988af8b659c70d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.components.reminder; + + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; +import android.util.Log; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.util.Prefs; + +@SuppressLint("BatteryLife") +public class DozeReminder { + private static final String TAG = DozeReminder.class.getSimpleName(); + + public static boolean isEligible(Context context) { + if(context==null) { + return false; + } + + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false; + } + + if(Prefs.getPrompteDozeMsgId(context)!=0) { + return false; + } + + // If we did never ask directly, we do not want to add a device message yet. First we want to try to ask directly. + if (!Prefs.getBooleanPreference(context, Prefs.DOZE_ASKED_DIRECTLY, false)) { + return false; + } + + PowerManager pm = (PowerManager)context.getSystemService(Context.POWER_SERVICE); + if(pm.isIgnoringBatteryOptimizations(context.getPackageName())) { + return false; + } + + // if the chatlist only contains device-talk and self-talk, just after installation, + // do not bother with battery, let the user check out other things first. + try { + int numberOfChats = DcHelper.getContext(context).getChatlist(0, null, 0).getCnt(); + if (numberOfChats <= 2) { + return false; + } + } + catch(Exception e) { + Log.e(TAG, "Error calling getChatlist()", e); + } + + return !isPushAvailableAndSufficient(); // yip, asking for disabling battery optimisations makes sense + } + + public static void addDozeReminderDeviceMsg(Context context) { + DcContext dcContext = DcHelper.getContext(context); + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + msg.setText("\uD83D\uDC49 "+context.getString(R.string.perm_enable_bg_reminder_title)+" \uD83D\uDC48\n\n" + +context.getString(R.string.pref_background_notifications_rationale)); + int msgId = dcContext.addDeviceMsg("android.doze-reminder", msg); + if(msgId!=0) { + Prefs.setPromptedDozeMsgId(context, msgId); + } + } + + public static boolean isDozeReminderMsg(Context context, DcMsg msg) { + return msg != null + && msg.getFromId() == DcContact.DC_CONTACT_ID_DEVICE + && msg.getId() == Prefs.getPrompteDozeMsgId(context); + } + + public static void dozeReminderTapped(Context context) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + PowerManager pm = (PowerManager)context.getSystemService(Context.POWER_SERVICE); + if(pm.isIgnoringBatteryOptimizations(context.getPackageName())) { + new AlertDialog.Builder(context) + .setMessage(R.string.perm_enable_bg_already_done) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; + } + + if(ContextCompat.checkSelfPermission(context, Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)==PackageManager.PERMISSION_GRANTED) { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + } + else { + Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + context.startActivity(intent); + } + } + + private static boolean isPushAvailableAndSufficient() { + return ApplicationContext.dcAccounts.isAllChatmail() + && FcmReceiveService.getToken() != null; + } + + public static void maybeAskDirectly(Context context) { + try { + if (isPushAvailableAndSufficient()) { + return; + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M + && !Prefs.getBooleanPreference(context, Prefs.DOZE_ASKED_DIRECTLY, false) + && ContextCompat.checkSelfPermission(context, Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_GRANTED + && !((PowerManager) context.getSystemService(Context.POWER_SERVICE)).isIgnoringBatteryOptimizations(context.getPackageName())) { + new AlertDialog.Builder(context) + .setTitle(R.string.pref_background_notifications) + .setMessage(R.string.pref_background_notifications_rationale) + .setPositiveButton(R.string.perm_continue, (dialog, which) -> { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + }) + .setCancelable(false) + .show(); + } + // Prefs.DOZE_ASKED_DIRECTLY is also used above in isEligible(). + // As long as Prefs.DOZE_ASKED_DIRECTLY is false, isEligible() will return false + // and no device message will be added. + Prefs.setBooleanPreference(context, Prefs.DOZE_ASKED_DIRECTLY, true); + } catch(Exception e) { + Log.e(TAG, "Error in maybeAskDirectly()", e); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java b/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java new file mode 100644 index 0000000000000000000000000000000000000000..27b133f672e37266b8b09b8a0f3a71d1e106e8c7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.components.subsampling; + + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; + +import com.davemorrissey.labs.subscaleview.decoder.ImageDecoder; +import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder; + +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.InputStream; + +public class AttachmentBitmapDecoder implements ImageDecoder{ + + public AttachmentBitmapDecoder() {} + + @Override + public Bitmap decode(Context context, Uri uri) throws Exception { + if (!PartAuthority.isLocalUri(uri)) { + return new SkiaImageDecoder().decode(context, uri); + } + + try (InputStream inputStream = PartAuthority.getAttachmentStream(context, uri)) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + + Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options); + + if (bitmap == null) { + throw new RuntimeException("Skia image region decoder returned null bitmap - image format may not be supported"); + } + + return bitmap; + } + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java b/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java new file mode 100644 index 0000000000000000000000000000000000000000..cf357f6f10f8c2e5fca0d7f4b4deaa15e81c4844 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.components.subsampling; + + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.util.Log; + +import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder; +import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder; + +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.InputStream; + +public class AttachmentRegionDecoder implements ImageRegionDecoder { + + private static final String TAG = AttachmentRegionDecoder.class.getName(); + + private SkiaImageRegionDecoder passthrough; + + private BitmapRegionDecoder bitmapRegionDecoder; + + @Override + public Point init(Context context, Uri uri) throws Exception { + Log.w(TAG, "Init!"); + if (!PartAuthority.isLocalUri(uri)) { + passthrough = new SkiaImageRegionDecoder(); + return passthrough.init(context, uri); + } + + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + + this.bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false); + inputStream.close(); + + return new Point(bitmapRegionDecoder.getWidth(), bitmapRegionDecoder.getHeight()); + } + + @Override + public Bitmap decodeRegion(Rect rect, int sampleSize) { + Log.w(TAG, "Decode region: " + rect); + + if (passthrough != null) { + return passthrough.decodeRegion(rect, sampleSize); + } + + synchronized(this) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + options.inPreferredConfig = Bitmap.Config.RGB_565; + + Bitmap bitmap = bitmapRegionDecoder.decodeRegion(rect, options); + + if (bitmap == null) { + throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported"); + } + + return bitmap; + } + } + + public boolean isReady() { + Log.w(TAG, "isReady"); + return (passthrough != null && passthrough.isReady()) || + (bitmapRegionDecoder != null && !bitmapRegionDecoder.isRecycled()); + } + + public void recycle() { + if (passthrough != null) { + passthrough.recycle(); + passthrough = null; + } else { + bitmapRegionDecoder.recycle(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java b/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java new file mode 100644 index 0000000000000000000000000000000000000000..ae679ddceae20990bc54efc492ca18c7db8877db --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.components.viewpager; + + +import androidx.viewpager.widget.ViewPager; + +public abstract class ExtendedOnPageChangedListener implements ViewPager.OnPageChangeListener { + + private Integer currentPage = null; + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + + } + + @Override + public void onPageSelected(int position) { + if (currentPage != null && currentPage != position) onPageUnselected(currentPage); + currentPage = position; + } + + public abstract void onPageUnselected(int position); + + @Override + public void onPageScrollStateChanged(int state) { + + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java b/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java new file mode 100644 index 0000000000000000000000000000000000000000..a170b7a74f8702f8465224c5cc38126e809ed2c8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.viewpager; + + +import android.content.Context; +import androidx.viewpager.widget.ViewPager; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; + +/** + * Hacky fix for http://code.google.com/p/android/issues/detail?id=18990 + *

+ * ScaleGestureDetector seems to mess up the touch events, which means that + * ViewGroups which make use of onInterceptTouchEvent throw a lot of + * IllegalArgumentException: pointerIndex out of range. + *

+ * There's not much I can do in my code for now, but we can mask the result by + * just catching the problem and ignoring it. + * + * @author Chris Banes + */ +public class HackyViewPager extends ViewPager { + + private static final String TAG = HackyViewPager.class.getSimpleName(); + + public HackyViewPager(Context context) { + super(context); + } + + public HackyViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + try { + return super.onInterceptTouchEvent(ev); + } catch (IllegalArgumentException e) { + Log.w(TAG, e); + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/connect/AccountManager.java b/src/main/java/org/thoughtcrime/securesms/connect/AccountManager.java new file mode 100644 index 0000000000000000000000000000000000000000..12d98b51c91b0a6f04a4f01eb384fc5f77c98ad2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/AccountManager.java @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.connect; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.preference.PreferenceManager; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.InstantOnboardingActivity; +import org.thoughtcrime.securesms.WelcomeActivity; +import org.thoughtcrime.securesms.accounts.AccountSelectionListFragment; + +import java.io.File; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class AccountManager { + + private static final String TAG = AccountManager.class.getSimpleName(); + private static final String LAST_ACCOUNT_ID = "last_account_id"; + private static AccountManager self; + + private void resetDcContext(Context context) { + ApplicationContext appContext = (ApplicationContext)context.getApplicationContext(); + appContext.dcContext = ApplicationContext.dcAccounts.getSelectedAccount(); + DcHelper.setStockTranslations(context); + DirectShareUtil.resetAllShortcuts(appContext); + } + + + // public api + + public static AccountManager getInstance() { + if (self == null) { + self = new AccountManager(); + } + return self; + } + + public void migrateToDcAccounts(ApplicationContext context) { + try { + int selectAccountId = 0; + + File[] files = context.getFilesDir().listFiles(); + if (files != null) { + for (File file : files) { + // old accounts have the pattern "messenger*.db" + if (!file.isDirectory() && file.getName().startsWith("messenger") && file.getName().endsWith(".db")) { + int accountId = ApplicationContext.dcAccounts.migrateAccount(file.getAbsolutePath()); + if (accountId != 0) { + String selName = PreferenceManager.getDefaultSharedPreferences(context) + .getString("curr_account_db_name", "messenger.db"); + if (file.getName().equals(selName)) { + // postpone selection as it will otherwise be overwritten by the next migrateAccount() call + // (if more than one account needs to be migrated) + selectAccountId = accountId; + } + } + } + } + } + + if (selectAccountId != 0) { + ApplicationContext.dcAccounts.selectAccount(selectAccountId); + } + } catch (Exception e) { + Log.e(TAG, "Error in migrateToDcAccounts()", e); + } + } + + public void switchAccount(Context context, int accountId) { + DcHelper.getAccounts(context).selectAccount(accountId); + resetDcContext(context); + } + + + // add accounts + + public int beginAccountCreation(Context context) { + Rpc rpc = DcHelper.getRpc(context); + DcAccounts accounts = DcHelper.getAccounts(context); + DcContext selectedAccount = accounts.getSelectedAccount(); + if (selectedAccount.isOk()) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(LAST_ACCOUNT_ID, selectedAccount.getAccountId()).apply(); + } + + int id = 0; + try { + id = rpc.addAccount(); + } catch (RpcException e) { + Log.e(TAG, "Error calling rpc.addAccount()", e); + } + resetDcContext(context); + return id; + } + + public boolean canRollbackAccountCreation(Context context) { + return DcHelper.getAccounts(context).getAll().length > 1; + } + + public void rollbackAccountCreation(Activity activity) { + DcAccounts accounts = DcHelper.getAccounts(activity); + + DcContext selectedAccount = accounts.getSelectedAccount(); + if (selectedAccount.isConfigured() == 0) { + accounts.removeAccount(selectedAccount.getAccountId()); + } + + int lastAccountId = PreferenceManager.getDefaultSharedPreferences(activity).getInt(LAST_ACCOUNT_ID, 0); + if (lastAccountId == 0 || !accounts.getAccount(lastAccountId).isOk()) { + lastAccountId = accounts.getSelectedAccount().getAccountId(); + } + switchAccountAndStartActivity(activity, lastAccountId); + } + + public void switchAccountAndStartActivity(Activity activity, int destAccountId) { + switchAccountAndStartActivity(activity, destAccountId, null); + } + + private void switchAccountAndStartActivity(Activity activity, int destAccountId, @Nullable String backupQr) { + if (destAccountId==0) { + beginAccountCreation(activity); + } else { + switchAccount(activity, destAccountId); + } + + activity.finishAffinity(); + if (destAccountId==0) { + Intent intent = new Intent(activity, WelcomeActivity.class); + if (backupQr != null) { + intent.putExtra(WelcomeActivity.BACKUP_QR_EXTRA, backupQr); + } + activity.startActivity(intent); + } else { + activity.startActivity(new Intent(activity.getApplicationContext(), ConversationListActivity.class)); + } + } + + // ui + + public void showSwitchAccountMenu(Activity activity) { + AccountSelectionListFragment dialog = new AccountSelectionListFragment(); + dialog.show(((FragmentActivity) activity).getSupportFragmentManager(), null); + } + + public void addAccountFromSecondDevice(Activity activity, String backupQr) { + switchAccountAndStartActivity(activity, 0, backupQr); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/AttachmentsContentProvider.java b/src/main/java/org/thoughtcrime/securesms/connect/AttachmentsContentProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..c8ffe406f233168eeee41e114dbe31b4e8becad0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/AttachmentsContentProvider.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.Objects; + +public class AttachmentsContentProvider extends ContentProvider { + + /* We save all attachments in our private files-directory + that cannot be read by other apps. + + When starting an Intent for viewing, we cannot use the paths. + Instead, we give a content://-url that results in calls to this class. + + (An alternative would be to copy files to view to a public directory, however, this would + lead to duplicate data. + Another alternative would be to write all attachments to a public directory, however, this + may lead to security problems as files are system-wide-readable and would also cause problems + if the user or another app deletes these files) */ + + @Override + public ParcelFileDescriptor openFile(Uri uri, @NonNull String mode) throws FileNotFoundException { + DcContext dcContext = DcHelper.getContext(Objects.requireNonNull(getContext())); + + // `uri` originally comes from DcHelper.openForViewOrShare() and + // looks like `content://chat.delta.attachments/ef39a39/text.txt` + // where ef39a39 is the file in the blob directory + // and text.txt is the original name of the file, as returned by `msg.getFilename()`. + // `uri.getPathSegments()` returns ["ef39a39", "text.txt"] in this example. + String file = uri.getPathSegments().get(0); + if (!DcHelper.sharedFiles.containsKey(file)) { + throw new FileNotFoundException("File was not shared before."); + } + + File privateFile = new File(dcContext.getBlobdir(), file); + return ParcelFileDescriptor.open(privateFile, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public int delete(@NonNull Uri arg0, String arg1, String[] arg2) { + return 0; + } + + @Override + public String getType(Uri uri) { + String file = uri.getPathSegments().get(0); + String mimeType = DcHelper.sharedFiles.get(file); + + return DcHelper.checkMime(uri.toString(), mimeType); + } + + @Override + public String getTypeAnonymous(Uri uri) { + String ext = MediaUtil.getFileExtensionFromUrl(uri.toString()); + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + } + + @Override + public Uri insert(@NonNull Uri arg0, ContentValues arg1) { + return null; + } + + @Override + public boolean onCreate() { + return false; + } + + @Override + public Cursor query(@NonNull Uri arg0, String[] arg1, String arg2, String[] arg3, + String arg4) { + return null; + } + + @Override + public int update(@NonNull Uri arg0, ContentValues arg1, String arg2, String[] arg3) { + return 0; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcContactsLoader.java b/src/main/java/org/thoughtcrime/securesms/connect/DcContactsLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..3715819f351f4707acc4e3420f6c32ca31492c5e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcContactsLoader.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.util.AsyncLoader; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +public class DcContactsLoader extends AsyncLoader { + + private final int listflags; + private final String query; + private final boolean addScanQRLink; + private final boolean addCreateGroupLinks; + private final boolean addCreateContactLink; + private final boolean blockedContacts; + + public DcContactsLoader(Context context, int listflags, String query, boolean addCreateGroupLinks, boolean addCreateContactLink, boolean addScanQRLink, boolean blockedContacts) { + super(context); + this.listflags = listflags; + this.query = (query==null||query.isEmpty())? null : query; + this.addScanQRLink = addScanQRLink; + this.addCreateGroupLinks = addCreateGroupLinks; + this.addCreateContactLink= addCreateContactLink; + this.blockedContacts = blockedContacts; + } + + @Override + public @NonNull + DcContactsLoader.Ret loadInBackground() { + DcContext dcContext = DcHelper.getContext(getContext()); + if (blockedContacts) { + int[] blocked_ids = dcContext.getBlockedContacts(); + return new Ret(blocked_ids); + } + + int[] contact_ids = dcContext.getContacts(listflags, query); + int[] additional_items = new int[0]; + if (query == null && addScanQRLink) + { + additional_items = Util.appendInt(additional_items, DcContact.DC_CONTACT_ID_QR_INVITE); + } + if (addCreateContactLink && !dcContext.isChatmail()) + { + additional_items = Util.appendInt(additional_items, DcContact.DC_CONTACT_ID_NEW_CLASSIC_CONTACT); + } + if (query == null && addCreateGroupLinks) { + additional_items = Util.appendInt(additional_items, DcContact.DC_CONTACT_ID_NEW_GROUP); + + final boolean broadcastsEnabled = Prefs.isNewBroadcastAvailable(getContext()); + if (broadcastsEnabled) additional_items = Util.appendInt(additional_items, DcContact.DC_CONTACT_ID_NEW_BROADCAST); + + if (!dcContext.isChatmail()) { + additional_items = Util.appendInt(additional_items, DcContact.DC_CONTACT_ID_NEW_UNENCRYPTED_GROUP); + } + } + int[] all_ids = new int[contact_ids.length + additional_items.length]; + System.arraycopy(additional_items, 0, all_ids, 0, additional_items.length); + System.arraycopy(contact_ids, 0, all_ids, additional_items.length, contact_ids.length); + return new Ret(all_ids); + } + + public static class Ret { + public final int[] ids; + + Ret(int[] ids) { + this.ids = ids; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java b/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java new file mode 100644 index 0000000000000000000000000000000000000000..4262dbd6c1cf83aeb08439dc2e116f3e935553ed --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java @@ -0,0 +1,246 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.Context; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.service.FetchForegroundService; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Hashtable; + +public class DcEventCenter { + private static final String TAG = DcEventCenter.class.getSimpleName(); + private @NonNull final Hashtable> currentAccountObservers = new Hashtable<>(); + private @NonNull final Hashtable> multiAccountObservers = new Hashtable<>(); + private final Object LOCK = new Object(); + private final @NonNull ApplicationContext context; + + public interface DcEventDelegate { + void handleEvent(@NonNull DcEvent event); + default boolean runOnMain() { + return true; + } + } + + public DcEventCenter(@NonNull Context context) { + this.context = ApplicationContext.getInstance(context); + } + + public void addObserver(int eventId, @NonNull DcEventDelegate observer) { + addObserver(currentAccountObservers, eventId, observer); + } + + public void addMultiAccountObserver(int eventId, @NonNull DcEventDelegate observer) { + addObserver(multiAccountObservers, eventId, observer); + } + + private void addObserver(Hashtable> observers, int eventId, @NonNull DcEventDelegate observer) { + synchronized (LOCK) { + ArrayList idObservers = observers.get(eventId); + if (idObservers == null) { + observers.put(eventId, (idObservers = new ArrayList<>())); + } + idObservers.add(observer); + } + } + + public void removeObserver(int eventId, DcEventDelegate observer) { + synchronized (LOCK) { + ArrayList idObservers = currentAccountObservers.get(eventId); + if (idObservers != null) { + idObservers.remove(observer); + } + idObservers = multiAccountObservers.get(eventId); + if (idObservers != null) { + idObservers.remove(observer); + } + } + } + + public void removeObservers(DcEventDelegate observer) { + synchronized (LOCK) { + for(Integer eventId : currentAccountObservers.keySet()) { + ArrayList idObservers = currentAccountObservers.get(eventId); + if (idObservers != null) { + idObservers.remove(observer); + } + } + for(Integer eventId : multiAccountObservers.keySet()) { + ArrayList idObservers = multiAccountObservers.get(eventId); + if (idObservers != null) { + idObservers.remove(observer); + } + } + } + } + + private void sendToMultiAccountObservers(@NonNull DcEvent event) { + sendToObservers(multiAccountObservers, event); + } + + private void sendToCurrentAccountObservers(@NonNull DcEvent event) { + sendToObservers(currentAccountObservers, event); + } + + private void sendToObservers(Hashtable> observers, @NonNull DcEvent event) { + synchronized (LOCK) { + ArrayList idObservers = observers.get(event.getId()); + if (idObservers != null) { + for (DcEventDelegate observer : idObservers) { + // using try/catch blocks as under some circumstances eg. getContext() may return NULL - + // and as this function is used virtually everywhere, also in libs, + // it's not feasible to check all single occurrences. + if(observer.runOnMain()) { + Util.runOnMain(() -> { + try { + observer.handleEvent(event); + } + catch(Exception e) { + Log.e(TAG, "Error calling observer.handleEvent()", e); + } + }); + } else { + Util.runOnBackground(() -> { + try { + observer.handleEvent(event); + } + catch (Exception e) { + Log.e(TAG, "Error calling observer.handleEvent()", e); + } + }); + } + } + } + } + } + + private final Object lastErrorLock = new Object(); + private boolean showNextErrorAsToast = true; + + public void captureNextError() { + synchronized (lastErrorLock) { + showNextErrorAsToast = false; + } + } + + public void endCaptureNextError() { + synchronized (lastErrorLock) { + showNextErrorAsToast = true; + } + } + + private void handleError(int event, String string) { + // log error + boolean showAsToast; + Log.e("DeltaChat", string); + synchronized (lastErrorLock) { + showAsToast = showNextErrorAsToast; + showNextErrorAsToast = true; + } + + // show error to user + Util.runOnMain(() -> { + if (showAsToast) { + String toastString = null; + + if (event == DcContext.DC_EVENT_ERROR_SELF_NOT_IN_GROUP) { + toastString = context.getString(R.string.group_self_not_in_group); + } + + ForegroundDetector foregroundDetector = ForegroundDetector.getInstance(); + if (toastString != null && (foregroundDetector == null || foregroundDetector.isForeground())) { + Toast.makeText(context, toastString, Toast.LENGTH_LONG).show(); + } + } + }); + } + + public long handleEvent(@NonNull DcEvent event) { + int accountId = event.getAccountId(); + int id = event.getId(); + + sendToMultiAccountObservers(event); + + switch (id) { + case DcContext.DC_EVENT_INCOMING_MSG: + DcHelper.getNotificationCenter(context).notifyMessage(accountId, event.getData1Int(), event.getData2Int()); + break; + + case DcContext.DC_EVENT_INCOMING_REACTION: + DcHelper.getNotificationCenter(context).notifyReaction(accountId, event.getData1Int(), event.getData2Int(), event.getData2Str()); + break; + + case DcContext.DC_EVENT_INCOMING_WEBXDC_NOTIFY: + DcHelper.getNotificationCenter(context).notifyWebxdc(accountId, event.getData1Int(), event.getData2Int(), event.getData2Str()); + break; + + case DcContext.DC_EVENT_INCOMING_CALL: + DcHelper.getNotificationCenter(context).notifyCall(accountId, event.getData1Int(), event.getData2Str()); + break; + + case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED: + case DcContext.DC_EVENT_CALL_ENDED: + DcHelper.getNotificationCenter(context).removeCallNotification(accountId, event.getData1Int()); + break; + + case DcContext.DC_EVENT_MSGS_NOTICED: + DcHelper.getNotificationCenter(context).removeNotifications(accountId, event.getData1Int()); + break; + + case DcContext.DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE: + FetchForegroundService.stop(context); + break; + + case DcContext.DC_EVENT_IMEX_PROGRESS: + sendToCurrentAccountObservers(event); + return 0; + } + + final String logPrefix = "[accId="+accountId + "] "; + switch (id) { + case DcContext.DC_EVENT_INFO: + Log.i("DeltaChat", logPrefix + event.getData2Str()); + break; + + case DcContext.DC_EVENT_WARNING: + Log.w("DeltaChat", logPrefix + event.getData2Str()); + break; + + case DcContext.DC_EVENT_ERROR: + Log.e("DeltaChat", logPrefix + event.getData2Str()); + break; + } + + if (accountId != context.dcContext.getAccountId()) { + return 0; + } + + switch (id) { + case DcContext.DC_EVENT_ERROR: + case DcContext.DC_EVENT_ERROR_SELF_NOT_IN_GROUP: + handleError(id, event.getData2Str()); + break; + + default: + sendToCurrentAccountObservers(event); + break; + } + + if (id == DcContext.DC_EVENT_CHAT_MODIFIED) { + // Possibly a chat was deleted or the avatar was changed, directly refresh DirectShare so that + // a new chat can move up / the chat avatar change is populated + DirectShareUtil.triggerRefreshDirectShare(context); + } + + return 0; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..18384cdcdfb8b60dfd0acd20d8c2fe7d1fcd2b5a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java @@ -0,0 +1,525 @@ +package org.thoughtcrime.securesms.connect; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.Settings; +import android.util.Log; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.LocalHelpActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.ShareActivity; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.notifications.NotificationCenter; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.qr.QrActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ShareUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.util.Date; +import java.util.HashMap; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class DcHelper { + + private static final String TAG = DcHelper.class.getSimpleName(); + + public static final String CONFIG_CONFIGURED_ADDRESS = "configured_addr"; + public static final String CONFIG_DISPLAY_NAME = "displayname"; + public static final String CONFIG_SELF_STATUS = "selfstatus"; + public static final String CONFIG_SELF_AVATAR = "selfavatar"; + public static final String CONFIG_MVBOX_MOVE = "mvbox_move"; + public static final String CONFIG_ONLY_FETCH_MVBOX = "only_fetch_mvbox"; + public static final String CONFIG_BCC_SELF = "bcc_self"; + public static final String CONFIG_SHOW_EMAILS = "show_emails"; + public static final String CONFIG_MEDIA_QUALITY = "media_quality"; + public static final String CONFIG_PROXY_ENABLED = "proxy_enabled"; + public static final String CONFIG_PROXY_URL = "proxy_url"; + public static final String CONFIG_WEBXDC_REALTIME_ENABLED = "webxdc_realtime_enabled"; + public static final String CONFIG_PRIVATE_TAG = "private_tag"; + + public static DcContext getContext(@NonNull Context context) { + return ApplicationContext.getInstance(context).dcContext; + } + + public static Rpc getRpc(@NonNull Context context) { + return ApplicationContext.getInstance(context).rpc; + } + + public static DcAccounts getAccounts(@NonNull Context context) { + return ApplicationContext.getInstance(context).dcAccounts; + } + + public static DcEventCenter getEventCenter(@NonNull Context context) { + return ApplicationContext.getInstance(context).eventCenter; + } + + public static NotificationCenter getNotificationCenter(@NonNull Context context) { + return ApplicationContext.getInstance(context).notificationCenter; + } + + public static boolean isConfigured(Context context) { + DcContext dcContext = getContext(context); + return dcContext.isConfigured() == 1; + } + + public static int getInt(Context context, String key) { + DcContext dcContext = getContext(context); + return dcContext.getConfigInt(key); + } + + public static String get(Context context, String key) { + DcContext dcContext = getContext(context); + return dcContext.getConfig(key); + } + + @Deprecated public static int getInt(Context context, String key, int defaultValue) { + return getInt(context, key); + } + + @Deprecated public static String get(Context context, String key, String defaultValue) { + return get(context, key); + } + + public static void set(Context context, String key, String value) { + DcContext dcContext = getContext(context); + dcContext.setConfig(key, value); + } + + public static void setStockTranslations(Context context) { + DcContext dcContext = getContext(context); + // the integers are defined in the core and used only here, an enum or sth. like that won't have a big benefit + dcContext.setStockTranslation(1, context.getString(R.string.chat_no_messages)); + dcContext.setStockTranslation(2, context.getString(R.string.self)); + dcContext.setStockTranslation(3, context.getString(R.string.draft)); + dcContext.setStockTranslation(7, context.getString(R.string.voice_message)); + dcContext.setStockTranslation(9, context.getString(R.string.image)); + dcContext.setStockTranslation(10, context.getString(R.string.video)); + dcContext.setStockTranslation(11, context.getString(R.string.audio)); + dcContext.setStockTranslation(12, context.getString(R.string.file)); + dcContext.setStockTranslation(23, context.getString(R.string.gif)); + dcContext.setStockTranslation(35, context.getString(R.string.contact_verified)); + dcContext.setStockTranslation(40, context.getString(R.string.chat_archived_label)); + dcContext.setStockTranslation(60, context.getString(R.string.login_error_cannot_login)); + dcContext.setStockTranslation(66, context.getString(R.string.location)); + dcContext.setStockTranslation(67, context.getString(R.string.sticker)); + dcContext.setStockTranslation(68, context.getString(R.string.device_talk)); + dcContext.setStockTranslation(69, context.getString(R.string.saved_messages)); + dcContext.setStockTranslation(70, context.getString(R.string.device_talk_explain)); + dcContext.setStockTranslation(71, context.getString(R.string.device_welcome_message, "https://i.delta.chat/#0A45953086F0C166D3BAF1D4BB2025496E4C2704&x=MVPi07rQBEmHO4FRb3brpwDe&j=n8mkKqu42WAKKUCx1bQOVh23&s=RxuXoa0vhvTs0QLsWM45Ues0&a=adb%40arcanechat.me&n=adb&b=ArcaneChat+Channel")); + dcContext.setStockTranslation(73, context.getString(R.string.systemmsg_subject_for_new_contact)); + dcContext.setStockTranslation(74, context.getString(R.string.systemmsg_failed_sending_to)); + dcContext.setStockTranslation(84, context.getString(R.string.configuration_failed_with_error)); + dcContext.setStockTranslation(85, context.getString(R.string.devicemsg_bad_time)); + dcContext.setStockTranslation(86, context.getString(R.string.devicemsg_update_reminder)); + dcContext.setStockTranslation(90, context.getString(R.string.reply_noun)); + dcContext.setStockTranslation(91, context.getString(R.string.devicemsg_self_deleted)); + dcContext.setStockTranslation(97, context.getString(R.string.forwarded)); + dcContext.setStockTranslation(98, context.getString(R.string.devicemsg_storage_exceeding)); + dcContext.setStockTranslation(99, context.getString(R.string.n_bytes_message)); + dcContext.setStockTranslation(100, context.getString(R.string.download_max_available_until)); + dcContext.setStockTranslation(103, context.getString(R.string.incoming_messages)); + dcContext.setStockTranslation(104, context.getString(R.string.outgoing_messages)); + dcContext.setStockTranslation(105, context.getString(R.string.storage_on_domain)); + dcContext.setStockTranslation(107, context.getString(R.string.connectivity_connected)); + dcContext.setStockTranslation(108, context.getString(R.string.connectivity_connecting)); + dcContext.setStockTranslation(109, context.getString(R.string.connectivity_updating)); + dcContext.setStockTranslation(110, context.getString(R.string.sending)); + dcContext.setStockTranslation(111, context.getString(R.string.last_msg_sent_successfully)); + dcContext.setStockTranslation(112, context.getString(R.string.error_x)); + dcContext.setStockTranslation(113, context.getString(R.string.not_supported_by_provider)); + dcContext.setStockTranslation(114, context.getString(R.string.messages)); + dcContext.setStockTranslation(116, context.getString(R.string.part_of_total_used)); + dcContext.setStockTranslation(117, context.getString(R.string.secure_join_started)); + dcContext.setStockTranslation(118, context.getString(R.string.secure_join_replies)); + dcContext.setStockTranslation(119, context.getString(R.string.qrshow_join_contact_hint)); + + // HACK: svg does not handle entities correctly and shows `"` as the text `quot;`. + // until that is fixed, we fix the most obvious errors (core uses encode_minimal, so this does not affect so many characters) + // cmp. https://github.com/deltachat/deltachat-android/issues/2187 + dcContext.setStockTranslation(120, context.getString(R.string.qrshow_join_group_hint).replace("\"", "")); + dcContext.setStockTranslation(121, context.getString(R.string.connectivity_not_connected)); + dcContext.setStockTranslation(124, context.getString(R.string.group_name_changed_by_you)); + dcContext.setStockTranslation(125, context.getString(R.string.group_name_changed_by_other)); + dcContext.setStockTranslation(126, context.getString(R.string.group_image_changed_by_you)); + dcContext.setStockTranslation(127, context.getString(R.string.group_image_changed_by_other)); + dcContext.setStockTranslation(128, context.getString(R.string.add_member_by_you)); + dcContext.setStockTranslation(129, context.getString(R.string.add_member_by_other)); + dcContext.setStockTranslation(130, context.getString(R.string.remove_member_by_you)); + dcContext.setStockTranslation(131, context.getString(R.string.remove_member_by_other)); + dcContext.setStockTranslation(132, context.getString(R.string.group_left_by_you)); + dcContext.setStockTranslation(133, context.getString(R.string.group_left_by_other)); + dcContext.setStockTranslation(134, context.getString(R.string.group_image_deleted_by_you)); + dcContext.setStockTranslation(135, context.getString(R.string.group_image_deleted_by_other)); + dcContext.setStockTranslation(136, context.getString(R.string.location_enabled_by_you)); + dcContext.setStockTranslation(137, context.getString(R.string.location_enabled_by_other)); + dcContext.setStockTranslation(138, context.getString(R.string.ephemeral_timer_disabled_by_you)); + dcContext.setStockTranslation(139, context.getString(R.string.ephemeral_timer_disabled_by_other)); + dcContext.setStockTranslation(140, context.getString(R.string.ephemeral_timer_seconds_by_you)); + dcContext.setStockTranslation(141, context.getString(R.string.ephemeral_timer_seconds_by_other)); + dcContext.setStockTranslation(144, context.getString(R.string.ephemeral_timer_1_hour_by_you)); + dcContext.setStockTranslation(145, context.getString(R.string.ephemeral_timer_1_hour_by_other)); + dcContext.setStockTranslation(146, context.getString(R.string.ephemeral_timer_1_day_by_you)); + dcContext.setStockTranslation(147, context.getString(R.string.ephemeral_timer_1_day_by_other)); + dcContext.setStockTranslation(148, context.getString(R.string.ephemeral_timer_1_week_by_you)); + dcContext.setStockTranslation(149, context.getString(R.string.ephemeral_timer_1_week_by_other)); + dcContext.setStockTranslation(150, context.getString(R.string.ephemeral_timer_minutes_by_you)); + dcContext.setStockTranslation(151, context.getString(R.string.ephemeral_timer_minutes_by_other)); + dcContext.setStockTranslation(152, context.getString(R.string.ephemeral_timer_hours_by_you)); + dcContext.setStockTranslation(153, context.getString(R.string.ephemeral_timer_hours_by_other)); + dcContext.setStockTranslation(154, context.getString(R.string.ephemeral_timer_days_by_you)); + dcContext.setStockTranslation(155, context.getString(R.string.ephemeral_timer_days_by_other)); + dcContext.setStockTranslation(156, context.getString(R.string.ephemeral_timer_weeks_by_you)); + dcContext.setStockTranslation(157, context.getString(R.string.ephemeral_timer_weeks_by_other)); + dcContext.setStockTranslation(158, context.getString(R.string.ephemeral_timer_1_year_by_you)); + dcContext.setStockTranslation(159, context.getString(R.string.ephemeral_timer_1_year_by_other)); + dcContext.setStockTranslation(162, context.getString(R.string.multidevice_qr_subtitle)); + dcContext.setStockTranslation(163, context.getString(R.string.multidevice_transfer_done_devicemsg)); + dcContext.setStockTranslation(170, context.getString(R.string.chat_protection_enabled_tap_to_learn_more)); + dcContext.setStockTranslation(172, context.getString(R.string.chat_new_group_hint)); + dcContext.setStockTranslation(173, context.getString(R.string.member_x_added)); + dcContext.setStockTranslation(174, context.getString(R.string.invalid_unencrypted_tap_to_learn_more)); + dcContext.setStockTranslation(176, context.getString(R.string.reaction_by_you)); + dcContext.setStockTranslation(177, context.getString(R.string.reaction_by_other)); + dcContext.setStockTranslation(178, context.getString(R.string.member_x_removed)); + dcContext.setStockTranslation(190, context.getString(R.string.secure_join_wait)); + dcContext.setStockTranslation(193, context.getString(R.string.donate_device_msg)); + dcContext.setStockTranslation(194, context.getString(R.string.outgoing_call)); + dcContext.setStockTranslation(195, context.getString(R.string.incoming_call)); + dcContext.setStockTranslation(196, context.getString(R.string.declined_call)); + dcContext.setStockTranslation(197, context.getString(R.string.canceled_call)); + dcContext.setStockTranslation(198, context.getString(R.string.missed_call)); + dcContext.setStockTranslation(200, context.getString(R.string.channel_left_by_you)); + dcContext.setStockTranslation(201, context.getString(R.string.qrshow_join_channel_hint)); + dcContext.setStockTranslation(202, context.getString(R.string.you_joined_the_channel)); + dcContext.setStockTranslation(203, context.getString(R.string.secure_join_channel_started)); + dcContext.setStockTranslation(210, context.getString(R.string.stats_msg_body)); + dcContext.setStockTranslation(220, context.getString(R.string.proxy_enabled)); + dcContext.setStockTranslation(221, context.getString(R.string.proxy_enabled_hint)); + dcContext.setStockTranslation(230, context.getString(R.string.chat_unencrypted_explanation)); + } + + public static File getImexDir() { + // DIRECTORY_DOCUMENTS could be used but DIRECTORY_DOWNLOADS seems to be easier accessible by the user, + // eg. "Download Managers" are nearly always installed. + // CAVE: do not use DownloadManager to add the file as it is deleted on uninstall then ... + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + } + + // When the user shares a file to another app or opens a file in another app, it is added here. + // `HashMap` where `file` is the name of the file in the blobdir (not the user-visible filename). + public static final HashMap sharedFiles = new HashMap<>(); + + public static void openForViewOrShare(Context activity, int msg_id, String cmd) { + DcContext dcContext = getContext(activity); + + if(!(activity instanceof Activity)) { + // would be nicer to accepting only Activity objects, + // however, typically in Android just Context objects are passed around (as this normally does not make a difference). + // Accepting only Activity here would force callers to cast, which would easily result in even more ugliness. + Toast.makeText(activity, "openForViewOrShare() expects an Activity object", Toast.LENGTH_LONG).show(); + return; + } + + DcMsg msg = dcContext.getMsg(msg_id); + String path = msg.getFile(); + String filename = msg.getFilename(); + String mimeType = msg.getFilemime(); + try { + File file = new File(path); + if (!file.exists()) { + Toast.makeText(activity, activity.getString(R.string.file_not_found, path), Toast.LENGTH_LONG).show(); + return; + } + + Uri uri; + mimeType = checkMime(filename, mimeType); + if (path.startsWith(dcContext.getBlobdir())) { + // Build a Uri that will later be passed to AttachmentsContentProvider.openFile(). + // The last part needs to be `filename`, i.e. the original, user-visible name of the file, + // so that the external apps show the name of the file correctly. + uri = Uri.parse("content://" + BuildConfig.APPLICATION_ID + ".attachments/" + Uri.encode(file.getName()) + "/" + Uri.encode(filename)); + + // As different Android version handle uris in putExtra differently, + // we also check on our own that the file was actually shared. + // The check happens in AttachmentsContentProvider.openFile(). + sharedFiles.put(file.getName(), mimeType); + } else { + if (Build.VERSION.SDK_INT >= 24) { + uri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileprovider", file); + } else { + uri = Uri.fromFile(file); + } + } + + if (cmd.equals(Intent.ACTION_VIEW)) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimeType); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity((Activity) activity, intent); + } else { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.putExtra(Intent.EXTRA_TEXT, msg.getText()); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.chat_share_with_title))); + } + } catch (RuntimeException e) { + Toast.makeText(activity, String.format("%s (%s)", activity.getString(R.string.no_app_to_handle_data), mimeType), Toast.LENGTH_LONG).show(); + Log.i(TAG, "opening of external activity failed.", e); + } + } + + public static void sendToChat(Context activity, byte[] data, String type, String fileName, String html, String subject, String text) { + Intent intent = new Intent(activity, ShareActivity.class); + intent.setAction(Intent.ACTION_SEND); + ShareUtil.setIsFromWebxdc(intent, true); + + if (data != null) { + String mimeType = "application/octet-stream"; + Uri uri = PersistentBlobProvider.getInstance().create(activity, data, mimeType, fileName); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, uri); + ShareUtil.setSharedType(intent, type); + ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_file_to, fileName)); + } else { + ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_message_to)); + } + + if (text != null) { + intent.putExtra(Intent.EXTRA_TEXT, text); + } + + if (subject != null) { + ShareUtil.setSharedSubject(intent, subject); + } + + if (html != null) { + String mimeType = "application/octet-stream"; + Uri uri = PersistentBlobProvider.getInstance().create(activity, html.getBytes(), mimeType, "index.html"); + ShareUtil.setSharedHtml(intent, uri); + } + + activity.startActivity(intent); + } + + private static void startActivity(Activity activity, Intent intent) { + // request for permission to install apks on API 26+ if intent mimetype is an apk + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + "application/vnd.android.package-archive".equals(intent.getType()) && + !activity.getPackageManager().canRequestPackageInstalls()) { + activity.startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).setData(Uri.parse(String.format("package:%s", activity.getPackageName())))); + return; + } + activity.startActivity(intent); + } + + public static String checkMime(String path, String mimeType) { + if(mimeType == null || mimeType.equals("application/octet-stream")) { + path = path.replaceAll(" ", ""); + String extension = MediaUtil.getFileExtensionFromUrl(path); + String newType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if(newType != null) return newType; + } + return mimeType; + } + + /** + * Return the path of a not-yet-existing file in the blobdir with roughly the given filename + * and the given extension. + * In many cases, since we're using setFileAndDeduplicate now, this wouldn't be necessary anymore + * and we could just create a file with a random filename, + * but there are a few usages that still need the current behavior (like `openMaps()`). + */ + public static String getBlobdirFile(DcContext dcContext, String filename, String ext) { + filename = FileUtils.sanitizeFilename(filename); + ext = FileUtils.sanitizeFilename(ext); + String outPath = null; + for (int i = 0; i < 1000; i++) { + String test = dcContext.getBlobdir() + "/" + filename + (i == 0 ? "" : i < 100 ? "-" + i : "-" + (new Date().getTime() + i)) + ext; + if (!new File(test).exists()) { + outPath = test; + break; + } + } + if(outPath==null) { + // should not happen + outPath = dcContext.getBlobdir() + "/" + Math.random(); + } + return outPath; + } + + public static String getBlobdirFile(DcContext dcContext, String path) { + String filename = path.substring(path.lastIndexOf('/')+1); // is the whole path if '/' is not found (lastIndexOf() returns -1 then) + String ext = ""; + int point = filename.indexOf('.'); + if(point!=-1) { + ext = filename.substring(point); + filename = filename.substring(0, point); + } + return getBlobdirFile(dcContext, filename, ext); + + } + + @NonNull + public static ThreadRecord getThreadRecord(Context context, DcLot summary, DcChat chat) { // adapted from ThreadDatabase.getCurrent() + int chatId = chat.getId(); + + String body = summary.getText1(); + if (!body.isEmpty()) { + body += ": "; + } + body += summary.getText2(); + + Recipient recipient = new Recipient(context, chat); + long date = summary.getTimestamp(); + int unreadCount = getContext(context).getFreshMsgCount(chatId); + + return new ThreadRecord(body, recipient, date, + unreadCount, chatId, + chat.getVisibility(), chat.isSendingLocations(), chat.isMuted(), chat.isContactRequest(), summary); + } + + public static boolean isNetworkConnected(Context context) { + try { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo netInfo = cm.getActiveNetworkInfo(); + if (netInfo != null && netInfo.isConnected()) { + return true; + } + + } catch (Exception ignored) { + } + return false; + } + + /** + * Gets a string you can show to the user with basic information about connectivity. + * @param context + * @param connectedString Usually "Connected", but when using this as the title in + * ConversationListActivity, we want to write "ArcaneChat" + * or the user's display name there instead. + * @return + */ + public static String getConnectivitySummary(Context context, String connectedString) { + int connectivity = getContext(context).getConnectivity(); + if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTED) { + return connectedString; + } else if (connectivity >= DcContext.DC_CONNECTIVITY_WORKING) { + return context.getString(R.string.connectivity_updating); + } else if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTING) { + return context.getString(R.string.connectivity_connecting); + } else { + return context.getString(R.string.connectivity_not_connected); + } + } + + public static void showProtectionEnabledDialog(Context context) { + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.chat_protection_enabled_explanation)) + .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#e2ee")) + .setPositiveButton(R.string.ok, null) + .setCancelable(true) + .show(); + } + + public static void showInvalidUnencryptedDialog(Context context) { + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.invalid_unencrypted_explanation)) + .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#howtoe2ee")) + .setNegativeButton(R.string.qrscan_title, (d, w) -> context.startActivity(new Intent(context, QrActivity.class))) + .setPositiveButton(R.string.ok, null) + .setCancelable(true) + .show(); + } + + public static void openHelp(Context context, String section) { + Intent intent = new Intent(context, LocalHelpActivity.class); + if (section != null) { intent.putExtra(LocalHelpActivity.SECTION_EXTRA, section); } + context.startActivity(intent); + } + + /** + * For the PGP-Contacts migration, things can go wrong. + * The migration happens when the account is setup, at which point no events can be sent yet. + * So, instead, if something goes wrong, it's returned by getLastError(). + * This function shows the error message to the user. + *

+ * A few releases after the PGP-contacts migration (which happened in 2025-05), + * we can remove this function again. + */ + public static void maybeShowMigrationError(Context context) { + try { + String lastError = DcHelper.getRpc(context).getMigrationError(DcHelper.getContext(context).getAccountId()); + + if (lastError != null && !lastError.isEmpty()) { + Log.w(TAG, "Opening account failed, trying to share error: " + lastError); + + String subject = "Delta Chat failed to update"; + String email = "delta@merlinux.eu"; + + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.error_x, lastError)) + .setNeutralButton(R.string.global_menu_edit_copy_desktop, (d, which) -> { + Util.writeTextToClipboard(context, lastError); + }) + .setPositiveButton(R.string.menu_send, (d, which) -> { + Intent sharingIntent = new Intent( + Intent.ACTION_SENDTO, Uri.fromParts( + "mailto", email, null + ) + ); + sharingIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{email}); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); + + if (sharingIntent.resolveActivity(context.getPackageManager()) == null) { + Log.w(TAG, "No email client found to send crash report"); + sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType("text/plain"); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); + sharingIntent.putExtra(Intent.EXTRA_EMAIL, email); + } + + Intent chooser = + Intent.createChooser(sharingIntent, "Send using..."); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + + context.startActivity(chooser); + }) + .setCancelable(false) + .show(); + } + } catch (RpcException e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DirectShareUtil.java b/src/main/java/org/thoughtcrime/securesms/connect/DirectShareUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..08238abfdb9692e350d84cb7dde45cf881a8fcd5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/DirectShareUtil.java @@ -0,0 +1,171 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContext; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.ShareActivity; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.DrawableUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * The Signal code has a similar class called ConversationUtil. + * + * This class uses the Sharing Shortcuts API to publish dynamic launcher shortcuts (the ones that + * appear when you long-press on an app) and direct-sharing-shortcuts. + * + * It replaces the class DirectShareService, because DirectShareService used the + * ChooserTargetService API, which was replaced by the Sharing Shortcuts API. + */ +public class DirectShareUtil { + + private static final String TAG = DirectShareUtil.class.getSimpleName(); + private static final String SHORTCUT_CATEGORY = "android.shortcut.conversation"; + + public static void clearShortcut(@NonNull Context context, int chatId) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Util.runOnAnyBackgroundThread(() -> { + try { + ShortcutManagerCompat.removeDynamicShortcuts(context, Collections.singletonList(Integer.toString(chatId))); + } catch (Exception e) { + Log.e(TAG, "Clearing shortcut failed", e); + } + }); + } + } + + public static void resetAllShortcuts(@NonNull Context context) { + Util.runOnBackground(() -> { + try { + ShortcutManagerCompat.removeAllDynamicShortcuts(context); + triggerRefreshDirectShare(context); + } catch (Exception e) { + Log.e(TAG, "Resetting shortcuts failed", e); + } + }); + } + + public static void triggerRefreshDirectShare(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + + Util.runOnBackgroundDelayed(() -> { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 + && context.getSystemService(ShortcutManager.class).isRateLimitingActive()) { + return; + } + + int maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context); + List currentShortcuts = ShortcutManagerCompat.getDynamicShortcuts(context); + List newShortcuts = getChooserTargets(context); + + if (maxShortcuts > 0 + && currentShortcuts.size() + newShortcuts.size() > maxShortcuts) { + ShortcutManagerCompat.removeAllDynamicShortcuts(context); + } + + boolean success = ShortcutManagerCompat.addDynamicShortcuts(context, newShortcuts); + Log.i(TAG, "Updated dynamic shortcuts, success: " + success); + } catch(Exception e) { + Log.e(TAG, "Updating dynamic shortcuts failed: " + e); + } + + // Wait 1500ms, this is called by onResume(), and we want to make sure that refreshing + // shortcuts does not delay loading of the chatlist + }, 1500); + } + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private static List getChooserTargets(Context context) { + List results = new LinkedList<>(); + DcContext dcContext = DcHelper.getContext(context); + + DcChatlist chatlist = dcContext.getChatlist( + DcContext.DC_GCL_FOR_FORWARDING | DcContext.DC_GCL_NO_SPECIALS, + null, + 0 + ); + int max = 5; + if (chatlist.getCnt() < max) { + max = chatlist.getCnt(); + } + for (int i = 0; i < max; i++) { + DcChat chat = chatlist.getChat(i); + if (!chat.canSend()) { + continue; + } + + Intent intent = new Intent(context, ShareActivity.class); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(ShareActivity.EXTRA_ACC_ID, dcContext.getAccountId()); + intent.putExtra(ShareActivity.EXTRA_CHAT_ID, chat.getId()); + + Recipient recipient = new Recipient(context, chat); + Bitmap avatar = getIconForShortcut(context, recipient); + results.add(new ShortcutInfoCompat.Builder(context, "chat-" + dcContext.getAccountId() + "-" + chat.getId()) + .setShortLabel(chat.getName()) + .setLongLived(true) + .setRank(i+1) + .setIcon(IconCompat.createWithAdaptiveBitmap(avatar)) + .setCategories(Collections.singleton(SHORTCUT_CATEGORY)) + .setIntent(intent) + .setActivity(new ComponentName(context, "org.thoughtcrime.securesms.RoutingActivity")) + .build()); + } + + return results; + } + + public static Bitmap getIconForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + try { + return getShortcutInfoBitmap(context, recipient); + } catch (ExecutionException | InterruptedException | NullPointerException e) { + return getFallbackDrawable(context, recipient); + } + } + + private static @NonNull Bitmap getShortcutInfoBitmap(@NonNull Context context, @NonNull Recipient recipient) throws ExecutionException, InterruptedException { + return DrawableUtil.wrapBitmapForShortcutInfo(request(GlideApp.with(context).asBitmap(), context, recipient).submit().get()); + } + + private static Bitmap getFallbackDrawable(Context context, @NonNull Recipient recipient) { + return BitmapUtil.createFromDrawable(recipient.getFallbackAvatarDrawable(context, false), + context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)); + } + + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + final ContactPhoto photo; + photo = recipient.getContactPhoto(context); + + return glideRequest.load(photo) + .error(getFallbackDrawable(context, recipient)) + .diskCacheStrategy(DiskCacheStrategy.ALL); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/FetchWorker.java b/src/main/java/org/thoughtcrime/securesms/connect/FetchWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..355f55b6c5376db2907dcc9967d42e7d594e4da2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/FetchWorker.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import org.thoughtcrime.securesms.util.Util; + +public class FetchWorker extends Worker { + private final @NonNull Context context; + + public FetchWorker( + @NonNull Context context, + @NonNull WorkerParameters params) { + super(context, params); + this.context = context; + } + + // doWork() is called in a background thread; + // once we return, Worker is considered to have finished and will be destroyed, + // this does not necessarily mean, that the app is killed, we may or may not keep running, + // therefore we do not stopIo() here. + @Override + public @NonNull Result doWork() { + Log.i("DeltaChat", "++++++++++++++++++ FetchWorker.doWork() started ++++++++++++++++++"); + DcHelper.getAccounts(context).startIo(); + + // as we do not know when startIo() has done it's work or if is even doable in one step, + // we go the easy way and just wait for some amount of time. + // the core has to handle interrupts at any point anyway, + // and work also maybe continued when doWork() returns. + // however, we should not wait too long here to avoid getting bad battery ratings. + Util.sleep(60 * 1000); + + Log.i("DeltaChat", "++++++++++++++++++ FetchWorker.doWork() will return ++++++++++++++++++"); + return Result.success(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/ForegroundDetector.java b/src/main/java/org/thoughtcrime/securesms/connect/ForegroundDetector.java new file mode 100644 index 0000000000000000000000000000000000000000..a88fdcf26e753fdf52a8901373efe2452f62fcb5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/ForegroundDetector.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.connect; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.ApplicationContext; + +@SuppressLint("NewApi") +public class ForegroundDetector implements Application.ActivityLifecycleCallbacks { + + private int refs = 0; + private static ForegroundDetector Instance = null; + private final ApplicationContext application; + + public static ForegroundDetector getInstance() { + return Instance; + } + + public ForegroundDetector(ApplicationContext application) { + Instance = this; + this.application = application; + application.registerActivityLifecycleCallbacks(this); + } + + public boolean isForeground() { + return refs > 0; + } + + public boolean isBackground() { + return refs == 0; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (refs == 0) { + Log.i("DeltaChat", "++++++++++++++++++ first ForegroundDetector.onActivityStarted() ++++++++++++++++++"); + DcHelper.getAccounts(application).startIo(); + if (DcHelper.isNetworkConnected(application)) { + new Thread(() -> { + Log.i("DeltaChat", "calling maybeNetwork()"); + DcHelper.getAccounts(application).maybeNetwork(); + Log.i("DeltaChat", "maybeNetwork() returned"); + }).start(); + } + } + + refs++; + } + + + @Override + public void onActivityStopped(@NonNull Activity activity) { + if( refs <= 0 ) { + Log.w("DeltaChat", "invalid call to ForegroundDetector.onActivityStopped()"); + return; + } + + refs--; + + if (refs == 0) { + Log.i("DeltaChat", "++++++++++++++++++ last ForegroundDetector.onActivityStopped() ++++++++++++++++++"); + } + } + + @Override + public void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) { + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + // pause/resume will also be called when the app is partially covered by a dialog + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/KeepAliveService.java b/src/main/java/org/thoughtcrime/securesms/connect/KeepAliveService.java new file mode 100644 index 0000000000000000000000000000000000000000..06a17dc82ac58b3cd4e85fbddebc35338af1474f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/KeepAliveService.java @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.connect; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.notifications.NotificationCenter; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Prefs; + +public class KeepAliveService extends Service { + + private static final String TAG = KeepAliveService.class.getSimpleName(); + + static KeepAliveService s_this = null; + + public static void maybeStartSelf(Context context) { + // note, that unfortunately, the check for isIgnoringBatteryOptimizations() is not sufficient, + // this checks only stock-android settings, several os have additional "optimizers" that ignore this setting. + // therefore, the most reliable way to not get killed is a permanent-foreground-notification. + if (Prefs.reliableService(context)) { + startSelf(context); + } + } + + public static void startSelf(Context context) + { + try { + ContextCompat.startForegroundService(context, new Intent(context, KeepAliveService.class)); + } + catch(Exception e) { + Log.i(TAG, "Error calling ContextCompat.startForegroundService()", e); + } + } + + @Override + public void onCreate() { + Log.i("DeltaChat", "*** KeepAliveService.onCreate()"); + // there's nothing more to do here as all initialisation stuff is already done in + // ApplicationLoader.onCreate() which is called before this broadcast is sended. + s_this = this; + + // set self as foreground + try { + stopForeground(true); + startForeground(NotificationCenter.ID_PERMANENT, createNotification()); + } + catch (Exception e) { + Log.i(TAG, "Error in onCreate()", e); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // START_STICKY ensured, the service is recreated as soon it is terminated for any reasons. + // as ApplicationLoader.onCreate() is called before a service starts, there is no more to do here, + // the app is just running fine. + Log.i("DeltaChat", "*** KeepAliveService.onStartCommand()"); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + Log.i("DeltaChat", "*** KeepAliveService.onDestroy()"); + // the service will be restarted due to START_STICKY automatically, there's nothing more to do. + } + + @Override + public void onTimeout(int startId, int fgsType) { + stopSelf(); + } + + static public KeepAliveService getInstance() + { + return s_this; // may be null + } + + /* The notification + * A notification is required for a foreground service; and without a foreground service, + * ArcaneChat won't get new messages reliable + **********************************************************************************************/ + + private Notification createNotification() + { + Intent intent = new Intent(this, ConversationListActivity.class); + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + // a notification _must_ contain a small icon, a title and a text, see https://developer.android.com/guide/topics/ui/notifiers/notifications.html#Required + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + + builder.setContentTitle(getString(R.string.app_name)); + builder.setContentText(getString(R.string.notify_background_connection_enabled)); + + builder.setPriority(NotificationCompat.PRIORITY_MIN); + builder.setWhen(0); + builder.setContentIntent(contentIntent); + builder.setSmallIcon(R.drawable.notification_permanent); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O) { + createFgNotificationChannel(this); + builder.setChannelId(NotificationCenter.CH_PERMANENT); + } + return builder.build(); + } + + private static boolean ch_created = false; + @TargetApi(Build.VERSION_CODES.O) + static private void createFgNotificationChannel(Context context) { + if(!ch_created) { + ch_created = true; + NotificationChannel channel = new NotificationChannel(NotificationCenter.CH_PERMANENT, + "Receive messages in background.", NotificationManager.IMPORTANCE_MIN); // IMPORTANCE_DEFAULT will play a sound + channel.setDescription("Ensure reliable message receiving."); + channel.setShowBadge(false); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/NetworkStateReceiver.java b/src/main/java/org/thoughtcrime/securesms/connect/NetworkStateReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..0a1d87b7165f939298046568519a3f90e31b67bf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/connect/NetworkStateReceiver.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.connect; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.util.Log; + +public class NetworkStateReceiver extends BroadcastReceiver { + + private static final String TAG = NetworkStateReceiver.class.getSimpleName(); + private int debugConnectedCount; + + @Override + public void onReceive(Context context, Intent intent) { + + try { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo ni = manager.getActiveNetworkInfo(); + + if (ni != null && ni.getState() == NetworkInfo.State.CONNECTED) { + Log.i("DeltaChat", "++++++++++++++++++ Connected #" + debugConnectedCount++); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + new Thread(() -> { + // call dc_maybe_network() from a worker thread. + // theoretically, dc_maybe_network() can be called from the main thread and returns at once, + // however, in reality, it does currently halt things for some seconds. + // this is a workaround that make things usable for now. + Log.i("DeltaChat", "calling maybeNetwork()"); + DcHelper.getAccounts(context).maybeNetwork(); + Log.i("DeltaChat", "maybeNetwork() returned"); + }).start(); + } + } + } + catch (Exception e) { + Log.i(TAG, "Error in onReceive()", e); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java new file mode 100644 index 0000000000000000000000000000000000000000..ebe027a96da900b3bea5a9543c0112e260c3e17f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -0,0 +1,114 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.database.Cursor; +import android.provider.ContactsContract; +import android.util.Log; + +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.util.Hash; +import org.thoughtcrime.securesms.util.Prefs; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * This class was originally a layer of indirection between + * ContactAccessorNewApi and ContactAccesorOldApi, which corresponded + * to the API changes between 1.x and 2.x. + * + * Now that we no longer support 1.x, this class mostly serves as a place + * to encapsulate Contact-related logic. It's still a singleton, mostly + * just because that's how it's currently called from everywhere. + * + * @author Moxie Marlinspike + */ + +public class ContactAccessor { + private static final String TAG = ContactSelectionListFragment.class.getSimpleName(); + + private static final int CONTACT_CURSOR_NAME = 0; + + private static final int CONTACT_CURSOR_MAIL = 1; + + private static final int CONTACT_CURSOR_CONTACT_ID = 2; + + private static final ContactAccessor instance = new ContactAccessor(); + + public static synchronized ContactAccessor getInstance() { + return instance; + } + + public Cursor getAllSystemContacts(Context context) { + String[] projection = {ContactsContract.Data.DISPLAY_NAME, ContactsContract.CommonDataKinds.Email.ADDRESS, ContactsContract.Data.CONTACT_ID}; + return context.getContentResolver().query(ContactsContract.CommonDataKinds.Email.CONTENT_URI, projection, null, null, null); + } + + public String getAllSystemContactsAsString(Context context) { + Cursor systemContactsCursor = getAllSystemContacts(context); + StringBuilder result = new StringBuilder(); + List mailList = new ArrayList<>(); + Set contactPhotoIdentifiers = new HashSet<>(); + while (systemContactsCursor != null && systemContactsCursor.moveToNext()) { + + String name; + try { + name = systemContactsCursor.getString(CONTACT_CURSOR_NAME); + if (name != null) { + name = name.replace("\r", ""); // remove characters later used as field separator + name = name.replace("\n", ""); + } else { + name = ""; + } + } catch(Exception e) { + Log.e(TAG, "Can't get contact name: " + e); + name = ""; + } + + String mail = null; + try { + mail = systemContactsCursor.getString(CONTACT_CURSOR_MAIL); + if (mail != null) { + mail = mail.replace("\r", ""); // remove characters later used as field separator + mail = mail.replace("\n", ""); + } + } catch(Exception e) { + Log.e(TAG, "Can't get contact addr: " + e); + } + + String contactId = systemContactsCursor.getString(CONTACT_CURSOR_CONTACT_ID); + if (contactId != null) { + String identifier = name + mail; + String hashedIdentifierAndId = Hash.sha256(identifier) + "|" + contactId; + contactPhotoIdentifiers.add(hashedIdentifierAndId); + } + if (mail != null && !mail.isEmpty() && !mailList.contains(mail)) { + mailList.add(mail); + if (name.isEmpty()) { + name = mail; + } + result.append(name).append("\n").append(mail).append("\n"); + } + } + Prefs.setSystemContactPhotos(context, contactPhotoIdentifiers); + return result.toString(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..300afece7755b396ca01fe606e456c90a8687d5a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -0,0 +1,324 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.util.SparseIntArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcContactsLoader; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.LRUCache; + +import java.lang.ref.SoftReference; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * List adapter to display all contacts and their related information + * + * @author Jake McGinty + */ +public class ContactSelectionListAdapter extends RecyclerView.Adapter +{ + private static final int VIEW_TYPE_CONTACT = 0; + private static final int MAX_CACHE_SIZE = 100; + + private final Map> recordCache = + Collections.synchronizedMap(new LRUCache>(MAX_CACHE_SIZE)); + + private final @NonNull Context context; + private final @NonNull DcContext dcContext; + private @NonNull int[] dcContactList = new int[0]; + private final boolean multiSelect; + private final boolean longPressSelect; + private final LayoutInflater li; + private final ItemClickListener clickListener; + private final GlideRequests glideRequests; + private final Set selectedContacts = new HashSet<>(); + private final SparseIntArray actionModeSelection = new SparseIntArray(); + + @Override + public int getItemCount() { + return dcContactList.length; + } + + private @NonNull DcContact getContact(int position) { + if(position<0 || position>=dcContactList.length) { + return new DcContact(0); + } + + final SoftReference reference = recordCache.get(position); + if (reference != null) { + final DcContact fromCache = reference.get(); + if (fromCache != null) { + return fromCache; + } + } + + final DcContact fromDb = dcContext.getContact(dcContactList[position]); + recordCache.put(position, new SoftReference<>(fromDb)); + return fromDb; + } + + public void resetActionModeSelection() { + actionModeSelection.clear(); + notifyDataSetChanged(); + } + + public void selectAll() { + actionModeSelection.clear(); + for(int index = 0; index < dcContactList.length; index++) { + int value = dcContactList[index]; + if (value > 0) { + actionModeSelection.put(index, value); + } + } + notifyDataSetChanged(); + } + + private boolean isActionModeEnabled() { + return actionModeSelection.size() != 0; + } + + public abstract static class ViewHolder extends RecyclerView.ViewHolder { + + public ViewHolder(View itemView) { + super(itemView); + } + + public abstract void bind(@NonNull GlideRequests glideRequests, int type, DcContact contact, String name, String number, String label, boolean multiSelect, boolean enabled); + public abstract void unbind(@NonNull GlideRequests glideRequests); + public abstract void setChecked(boolean checked); + public abstract void setSelected(boolean enabled); + public abstract void setEnabled(boolean enabled); + } + + public class ContactViewHolder extends ViewHolder { + + ContactViewHolder(@NonNull final View itemView, + @Nullable final ItemClickListener clickListener) { + super(itemView); + itemView.setOnClickListener(view -> { + if (clickListener != null) { + if (isActionModeEnabled()) { + toggleSelection(); + clickListener.onItemClick(getView(), true); + } else { + clickListener.onItemClick(getView(), false); + } + } + }); + itemView.setOnLongClickListener(view -> { + if (clickListener != null) { + int contactId = getContactId(getAdapterPosition()); + if (contactId > 0) { + toggleSelection(); + clickListener.onItemLongClick(getView()); + } + } + return true; + }); + } + + private int getContactId(int adapterPosition) { + return ContactSelectionListAdapter.this.dcContactList[adapterPosition]; + } + + private void toggleSelection() { + if (!longPressSelect) { + return; + } + int adapterPosition = getBindingAdapterPosition(); + if (adapterPosition < 0) return; + int contactId = getContactId(adapterPosition); + boolean enabled = actionModeSelection.indexOfKey(adapterPosition) > -1; + if (enabled) { + ContactSelectionListAdapter.this.actionModeSelection.delete(adapterPosition); + } else { + ContactSelectionListAdapter.this.actionModeSelection.put(adapterPosition, contactId); + } + notifyDataSetChanged(); + } + + public ContactSelectionListItem getView() { + return (ContactSelectionListItem) itemView; + } + + public void bind(@NonNull GlideRequests glideRequests, int type, DcContact contact, String name, String addr, String label, boolean multiSelect, boolean enabled) { + getView().set(glideRequests, type, contact, name, addr, label, multiSelect, enabled); + } + + @Override + public void unbind(@NonNull GlideRequests glideRequests) { + getView().unbind(glideRequests); + } + + @Override + public void setChecked(boolean checked) { + getView().setChecked(checked); + } + + @Override + public void setSelected(boolean enabled) { + getView().setSelected(enabled); + } + + @Override + public void setEnabled(boolean enabled) { + getView().setEnabled(enabled); + } + } + + public static class DividerViewHolder extends ViewHolder { + + private final TextView label; + + DividerViewHolder(View itemView) { + super(itemView); + this.label = itemView.findViewById(R.id.label); + } + + @Override + public void bind(@NonNull GlideRequests glideRequests, int type, DcContact contact, String name, String number, String label, boolean multiSelect, boolean enabled) { + this.label.setText(name); + } + + @Override + public void unbind(@NonNull GlideRequests glideRequests) {} + + @Override + public void setChecked(boolean checked) {} + + @Override + public void setSelected(boolean enabled) { + } + + @Override + public void setEnabled(boolean enabled) { + + } + } + + public ContactSelectionListAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @Nullable ItemClickListener clickListener, + boolean multiSelect, + boolean longPressSelect) + { + super(); + this.context = context; + this.dcContext = DcHelper.getContext(context); + this.li = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.multiSelect = multiSelect; + this.clickListener = clickListener; + this.longPressSelect = longPressSelect; + } + + @NonNull + @Override + public ContactSelectionListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == VIEW_TYPE_CONTACT) { + return new ContactViewHolder(li.inflate(R.layout.contact_selection_list_item, parent, false), clickListener); + } else { + return new DividerViewHolder(li.inflate(R.layout.contact_selection_list_divider, parent, false)); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder viewHolder, int i) { + + int id = dcContactList[i]; + DcContact dcContact = null; + String name; + String addr = null; + boolean itemMultiSelect = multiSelect; + + if (id == DcContact.DC_CONTACT_ID_NEW_CLASSIC_CONTACT) { + name = context.getString(R.string.menu_new_classic_contact); + itemMultiSelect = false; // the item creates a new contact in the list that will be selected instead + } else if (id == DcContact.DC_CONTACT_ID_NEW_GROUP) { + name = context.getString(R.string.menu_new_group); + } else if (id == DcContact.DC_CONTACT_ID_NEW_UNENCRYPTED_GROUP) { + name = context.getString(R.string.new_email); + } else if (id == DcContact.DC_CONTACT_ID_NEW_BROADCAST) { + name = context.getString(R.string.new_channel); + } else if (id == DcContact.DC_CONTACT_ID_QR_INVITE) { + name = context.getString(R.string.menu_new_contact); + } else { + dcContact = getContact(i); + name = dcContact.getDisplayName(); + addr = dcContact.getAddr(); + } + + viewHolder.unbind(glideRequests); + boolean enabled = true; + if (dcContact == null) { + viewHolder.setSelected(false); + viewHolder.setEnabled(!isActionModeEnabled()); + if (isActionModeEnabled()) { + enabled = false; + } + } else { + boolean selected = actionModeSelection.indexOfValue(id) > -1; + viewHolder.setSelected(selected); + enabled = !(dcContact.getId() == DcContact.DC_CONTACT_ID_SELF && itemMultiSelect); + } + viewHolder.bind(glideRequests, id, dcContact, name, addr, null, itemMultiSelect, enabled); + viewHolder.setChecked(selectedContacts.contains(id)); + } + + @Override + public int getItemViewType(int i) { + return VIEW_TYPE_CONTACT; + } + + public Set getSelectedContacts() { + return selectedContacts; + } + + public SparseIntArray getActionModeSelection() { + return actionModeSelection; + } + + public interface ItemClickListener { + void onItemClick(ContactSelectionListItem item, boolean handleActionMode); + + void onItemLongClick(ContactSelectionListItem view); + } + + public void changeData(DcContactsLoader.Ret loaderRet) { + this.dcContactList = loaderRet==null? new int[0] : loaderRet.ids; + recordCache.clear(); + notifyDataSetChanged(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java b/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java new file mode 100644 index 0000000000000000000000000000000000000000..20a1872230be1261ea89d3842efe272e597b1d03 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContact; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarView; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class ContactSelectionListItem extends LinearLayout implements RecipientModifiedListener { + + private AvatarView avatar; + private View numberContainer; + private TextView numberView; + private TextView nameView; + private TextView labelView; + private CheckBox checkBox; + + private int specialId; + private String name; + private String number; + private Recipient recipient; + private GlideRequests glideRequests; + + public ContactSelectionListItem(Context context) { + super(context); + } + + public ContactSelectionListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.avatar = findViewById(R.id.avatar); + this.numberContainer = findViewById(R.id.number_container); + this.numberView = findViewById(R.id.number); + this.labelView = findViewById(R.id.label); + this.nameView = findViewById(R.id.name); + this.checkBox = findViewById(R.id.check_box); + + ViewUtil.setTextViewGravityStart(this.nameView, getContext()); + } + + public void set(@NonNull GlideRequests glideRequests, int specialId, DcContact contact, String name, String number, String label, boolean multiSelect, boolean enabled) { + this.glideRequests = glideRequests; + this.specialId = specialId; + this.name = name; + this.number = number; + + if (specialId==DcContact.DC_CONTACT_ID_NEW_CLASSIC_CONTACT + || specialId==DcContact.DC_CONTACT_ID_NEW_GROUP + || specialId==DcContact.DC_CONTACT_ID_NEW_UNENCRYPTED_GROUP + || specialId==DcContact.DC_CONTACT_ID_NEW_BROADCAST + || specialId==DcContact.DC_CONTACT_ID_ADD_MEMBER + || specialId==DcContact.DC_CONTACT_ID_QR_INVITE) { + this.nameView.setTypeface(null, Typeface.BOLD); + } + else { + this.recipient = new Recipient(getContext(), contact); + this.recipient.addListener(this); + if (this.recipient.getName() != null) { + name = this.recipient.getName(); + } + this.nameView.setTypeface(null, Typeface.NORMAL); + } + if (specialId == DcContact.DC_CONTACT_ID_QR_INVITE) { + this.avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_qr_code_24).asDrawable(getContext(), ThemeUtil.getDummyContactColor(getContext()))); + } else { + this.avatar.setAvatar(glideRequests, recipient, false); + } + this.avatar.setSeenRecently(contact != null && contact.wasSeenRecently()); + + setText(name, number, label, contact); + setEnabled(enabled); + + if (multiSelect) this.checkBox.setVisibility(View.VISIBLE); + else this.checkBox.setVisibility(View.GONE); + } + + public void setChecked(boolean selected) { + this.checkBox.setChecked(selected); + } + + public void unbind(GlideRequests glideRequests) { + if (recipient != null) { + recipient.removeListener(this); + recipient = null; + } + + avatar.clear(glideRequests); + } + + private void setText(String name, String number, String label, DcContact contact) { + this.nameView.setEnabled(true); + this.nameView.setText(name==null? "#" : name); + + if (contact != null) { + if (contact.isBot()) { + number = getContext().getString(R.string.bot); + } else if (contact.wasSeenRecently()) { + number = getContext().getString(R.string.online); + } else { + long timestamp = contact.getLastSeen(); + if (timestamp != 0) { + number = getContext().getString(R.string.last_seen_at, DateUtils.getExtendedTimeSpanString(getContext(), timestamp)); + } + } + } + + if(number!=null) { + this.numberView.setText(number); + this.labelView.setText(label==null? "" : label); + this.numberContainer.setVisibility(View.VISIBLE); + } + else { + this.numberContainer.setVisibility(View.GONE); + } + } + + public int getSpecialId() { + return specialId; + } + + public String getName() { + return name; + } + + public String getNumber() { + return number; + } + + public DcContact getDcContact() { + return recipient.getDcContact(); + } + + public int getContactId() { + if (recipient.getAddress().isDcContact()) { + return recipient.getAddress().getDcContactId(); + } else { + return -1; + } + } + + @Override + public void onModified(final Recipient recipient) { + if (this.recipient == recipient) { + Util.runOnMain(() -> { + avatar.setAvatar(glideRequests, recipient, false); + DcContact contact = recipient.getDcContact(); + avatar.setSeenRecently(contact != null && contact.wasSeenRecently()); + nameView.setText(recipient.toShortString()); + }); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/NewContactActivity.java b/src/main/java/org/thoughtcrime/securesms/contacts/NewContactActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..a9b11e9603ee2f060094f03017ec7f97be70b9c3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/NewContactActivity.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; + +import com.b44t.messenger.DcContext; +import com.google.android.material.textfield.TextInputEditText; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.ConversationActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.qr.QrCodeHandler; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class NewContactActivity extends PassphraseRequiredActionBarActivity +{ + + public static final String ADDR_EXTRA = "contact_addr"; + public static final String CONTACT_ID_EXTRA = "contact_id"; + + private TextInputEditText nameInput; + private TextInputEditText addrInput; + private DcContext dcContext; + + @Override + protected void onCreate(Bundle state, boolean ready) { + dcContext = DcHelper.getContext(this); + setContentView(R.layout.new_contact_activity); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.menu_new_classic_contact); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + } + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.content_container)); + + nameInput = ViewUtil.findById(this, R.id.name_text); + addrInput = ViewUtil.findById(this, R.id.email_text); + addrInput.setText(getIntent().getStringExtra(ADDR_EXTRA)); + addrInput.setOnFocusChangeListener((view, focused) -> { + String addr = addrInput.getText() == null? "" : addrInput.getText().toString(); + if(!focused && !dcContext.mayBeValidAddr(addr)) { + addrInput.setError(getString(R.string.login_error_mail)); + } + }); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + inflater.inflate(R.menu.new_contact, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.menu_create_contact) { + String addr = addrInput.getText() == null ? "" : addrInput.getText().toString(); + String name = nameInput.getText() == null ? "" : nameInput.getText().toString(); + if (name.isEmpty()) name = null; + int contactId = dcContext.mayBeValidAddr(addr) ? dcContext.createContact(name, addr) : 0; + if (contactId == 0) { + Toast.makeText(this, getString(R.string.login_error_mail), Toast.LENGTH_LONG).show(); + return true; + } + if (getCallingActivity() != null) { // called for result + Intent intent = new Intent(); + intent.putExtra(CONTACT_ID_EXTRA, contactId); + setResult(RESULT_OK, intent); + } else { + int chatId = dcContext.createChatByContactId(contactId); + Intent intent = new Intent(this, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + startActivity(intent); + } + finish(); + return true; + } + return false; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == IntentIntegrator.REQUEST_CODE) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.onScanPerformed(scanResult); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..8c9120d34ef7fc0e92dbdc594aeea31e218ac524 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; + +import java.io.IOException; +import java.io.InputStream; + +public interface ContactPhoto extends Key { + + InputStream openInputStream(Context context) throws IOException; + + @Nullable Uri getUri(@NonNull Context context); + + boolean isProfilePhoto(); + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..d33323098a07b79bf73adad33242a5461480e848 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +public interface FallbackContactPhoto { + + public Drawable asDrawable(Context context, int color); + public Drawable asCallCard(Context context); + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..2e87281e946eb807c9011913ffa5801fef272326 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; + +import org.thoughtcrime.securesms.R; + +import java.util.regex.Pattern; + +public class GeneratedContactPhoto implements FallbackContactPhoto { + + private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{P}\\p{S}]+"); + + private final String name; + + public GeneratedContactPhoto(@NonNull String name) { + this.name = name; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color, true); + } + + public Drawable asDrawable(Context context, int color, boolean roundShape) { + int targetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); + + TextDrawable.IShapeBuilder builder = TextDrawable.builder() + .beginConfig() + .width(targetSize) + .height(targetSize) + .textColor(Color.WHITE) + .bold() + .toUpperCase() + .endConfig(); + return roundShape? builder.buildRound(getCharacter(name), color) : builder.buildRect(getCharacter(name), color); + } + + private String getCharacter(String name) { + String cleanedName = PATTERN.matcher(name).replaceFirst(""); + + if (cleanedName.isEmpty()) { + return "#"; + } else { + return new StringBuilder().appendCodePoint(cleanedName.codePointAt(0)).toString(); + } + } + + @Override + public Drawable asCallCard(Context context) { + return AppCompatResources.getDrawable(context, R.drawable.ic_person_large); + + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..00b24e5c333331155537609d81ce9f748439559d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; + +import com.b44t.messenger.DcChat; + +import org.thoughtcrime.securesms.database.Address; + +public class GroupRecordContactPhoto extends LocalFileContactPhoto { + + public GroupRecordContactPhoto(Context context, Address address, DcChat dcChat) { + super(context, address, dcChat, null); + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + int getId() { + return address.getDcChatId(); + } + + @Override + public String getPath(Context context) { + String profileImage = dcChat.getProfileImage(); + return profileImage != null ? profileImage : ""; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/LocalFileContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/LocalFileContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..dac6727561f6bb1f95532fae8ff6d7f836a8c53c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/LocalFileContactPhoto.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; + +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.util.Conversions; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; + +public abstract class LocalFileContactPhoto implements ContactPhoto { + + final Address address; + final DcChat dcChat; + final DcContact dcContact; + + private final int id; + + private final String path; + + LocalFileContactPhoto(Context context, Address address, DcChat dcChat, DcContact dcContact) { + this.address = address; + this.dcChat = dcChat; + this.dcContact = dcContact; + id = getId(); + path = getPath(context); + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + return new FileInputStream(path); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return isProfilePhoto() ? Uri.fromFile(AvatarHelper.getSelfAvatarFile(context)) : null; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(address.serialize().getBytes()); + messageDigest.update(Conversions.longToByteArray(id)); + messageDigest.update(path.getBytes()); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof LocalFileContactPhoto)) return false; + + LocalFileContactPhoto that = (LocalFileContactPhoto) other; + return this.address.equals(that.address) && this.id == that.id && this.path.equals(that.path); + } + + @Override + public int hashCode() { + return this.address.hashCode() ^ id; + } + + abstract int getId(); + + abstract public String getPath(Context context); + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/MyProfileContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/MyProfileContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..767d99a5d7a3b5a1504a0a564502cd9b98353db9 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/MyProfileContactPhoto.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.profiles.AvatarHelper; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; + +public class MyProfileContactPhoto implements ContactPhoto { + + private final @NonNull String address; + private final @NonNull String avatarObject; + + public MyProfileContactPhoto(@NonNull String address, @NonNull String avatarObject) { + this.address = address; + this.avatarObject = avatarObject; + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + return new FileInputStream(AvatarHelper.getSelfAvatarFile(context)); + } + + @Override + public @Nullable + Uri getUri(@NonNull Context context) { + return Uri.fromFile(AvatarHelper.getSelfAvatarFile(context)); + } + + @Override + public boolean isProfilePhoto() { + return true; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(address.getBytes()); + messageDigest.update(avatarObject.getBytes()); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof MyProfileContactPhoto)) return false; + + MyProfileContactPhoto that = (MyProfileContactPhoto) other; + + return this.address.equals(that.address) && this.avatarObject.equals(that.avatarObject); + } + + @Override + public int hashCode() { + return address.hashCode() ^ avatarObject.hashCode(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..45c8bef0054e7efc4905c00c94cc5404eff9cc09 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; + +import com.b44t.messenger.DcContact; + +import org.thoughtcrime.securesms.database.Address; + +public class ProfileContactPhoto extends LocalFileContactPhoto { + + public ProfileContactPhoto(Context context, Address address, DcContact dcContact) { + super(context, address, null, dcContact); + } + + @Override + public boolean isProfilePhoto() { + return true; + } + + @Override + int getId() { + return address.getDcContactId(); + } + + @Override + public String getPath(Context context) { + String profileImage = dcContact.getProfileImage(); + return profileImage != null ? profileImage : ""; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..0bd2961472d761e4104f85ff17383eb2afe6d8a3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.widget.ImageView; + +import androidx.annotation.DrawableRes; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.makeramen.roundedimageview.RoundedDrawable; + +public class ResourceContactPhoto implements FallbackContactPhoto { + + private final int resourceId; + private final int callCardResourceId; + + public ResourceContactPhoto(@DrawableRes int resourceId) { + this(resourceId, resourceId); + } + + public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int callCardResourceId) { + this.resourceId = resourceId; + this.callCardResourceId = callCardResourceId; + } + + @Override + public Drawable asDrawable(Context context, int color) { + Drawable background = TextDrawable.builder().buildRound(" ", color); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); + + foreground.setScaleType(ImageView.ScaleType.CENTER); + + return new ExpandingLayerDrawable(new Drawable[] {background, foreground}); + } + + @Override + public Drawable asCallCard(Context context) { + return AppCompatResources.getDrawable(context, callCardResourceId); + } + + private static class ExpandingLayerDrawable extends LayerDrawable { + public ExpandingLayerDrawable(Drawable[] layers) { + super(layers); + } + + @Override + public int getIntrinsicWidth() { + return -1; + } + + @Override + public int getIntrinsicHeight() { + return -1; + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..8db2a7a5eb60f78b27a6a79bc274b2f7dc60f47b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.util.Conversions; + +import java.io.InputStream; +import java.security.MessageDigest; + +public class SystemContactPhoto implements ContactPhoto { + + private final @NonNull Address address; + private final @NonNull Uri contactPhotoUri; + private final long lastModifiedTime; + + public SystemContactPhoto(@NonNull Address address, @NonNull Uri contactPhotoUri, long lastModifiedTime) { + this.address = address; + this.contactPhotoUri = contactPhotoUri; + this.lastModifiedTime = lastModifiedTime; + } + + @Override + public InputStream openInputStream(Context context) { + return ContactsContract.Contacts.openContactPhotoInputStream(context.getContentResolver(), contactPhotoUri, true); + } + + @Nullable + @Override + public Uri getUri(@NonNull Context context) { + return contactPhotoUri; + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + public void updateDiskCacheKey(MessageDigest messageDigest) { + messageDigest.update(address.serialize().getBytes()); + messageDigest.update(contactPhotoUri.toString().getBytes()); + messageDigest.update(Conversions.longToByteArray(lastModifiedTime)); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof SystemContactPhoto)) return false; + + SystemContactPhoto that = (SystemContactPhoto)other; + + return this.address.equals(that.address) && this.contactPhotoUri.equals(that.contactPhotoUri) && this.lastModifiedTime == that.lastModifiedTime; + } + + @Override + public int hashCode() { + return address.hashCode() ^ contactPhotoUri.hashCode() ^ (int)lastModifiedTime; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/contacts/avatars/VcardContactPhoto.java b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/VcardContactPhoto.java new file mode 100644 index 0000000000000000000000000000000000000000..a15fbc697d6841b676eeca779a9ab8ebb3ad17fd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/contacts/avatars/VcardContactPhoto.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; + +import chat.delta.rpc.types.VcardContact; + +public class VcardContactPhoto implements ContactPhoto { + private final VcardContact vContact; + + public VcardContactPhoto(VcardContact vContact) { + this.vContact = vContact; + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + byte[] blob = JsonUtils.decodeBase64(vContact.profileImage); + return (blob == null)? null : new ByteArrayInputStream(blob); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return null; + } + + @Override + public boolean isProfilePhoto() { + return true; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(vContact.addr.getBytes()); + messageDigest.update(ByteBuffer.allocate(4).putFloat(vContact.timestamp).array()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java b/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java new file mode 100644 index 0000000000000000000000000000000000000000..bdd425e0c815ac38fe0fbbf916f8522e3b75656f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.crypto; + + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.Hex; + +import java.io.IOException; + +public class DatabaseSecret { + + private final byte[] key; + private final String encoded; + + public DatabaseSecret(@NonNull byte[] key) { + this.key = key; + this.encoded = Hex.toStringCondensed(key); + } + + public DatabaseSecret(@NonNull String encoded) throws IOException { + this.key = Hex.fromStringCondensed(encoded); + this.encoded = encoded; + } + + public String asString() { + return encoded; + } + + public byte[] asBytes() { + return key; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java b/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..3336b29934ba7ea3f63cb93782f362ddc010dc8b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.Prefs; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.concurrent.ConcurrentHashMap; + +/** + * It can be rather expensive to read from the keystore, so this class caches the key in memory + * after it is created. + */ +public final class DatabaseSecretProvider { + + private static final ConcurrentHashMap instances = new ConcurrentHashMap<>(); + + public static DatabaseSecret getOrCreateDatabaseSecret(@NonNull Context context, int accountId) { + if (instances.get(accountId) == null) { + synchronized (DatabaseSecretProvider.class) { + if (instances.get(accountId) == null) { + instances.put(accountId, getOrCreate(context, accountId)); + } + } + } + + return instances.get(accountId); + } + + private DatabaseSecretProvider() { + } + + private static @NonNull DatabaseSecret getOrCreate(@NonNull Context context, int accountId) { + String unencryptedSecret = Prefs.getDatabaseUnencryptedSecret(context, accountId); + String encryptedSecret = Prefs.getDatabaseEncryptedSecret(context, accountId); + + if (unencryptedSecret != null) return getUnencryptedDatabaseSecret(context, unencryptedSecret, accountId); + else if (encryptedSecret != null) return getEncryptedDatabaseSecret(encryptedSecret); + else return createAndStoreDatabaseSecret(context, accountId); + } + + private static @NonNull DatabaseSecret getUnencryptedDatabaseSecret(@NonNull Context context, @NonNull String unencryptedSecret, int accountId) + { + try { + DatabaseSecret databaseSecret = new DatabaseSecret(unencryptedSecret); + + if (Build.VERSION.SDK_INT < 23) { + return databaseSecret; + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes()); + + Prefs.setDatabaseEncryptedSecret(context, encryptedSecret.serialize(), accountId); + Prefs.setDatabaseUnencryptedSecret(context, null, accountId); + + return databaseSecret; + } + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static @NonNull DatabaseSecret getEncryptedDatabaseSecret(@NonNull String serializedEncryptedSecret) { + if (Build.VERSION.SDK_INT < 23) { + throw new AssertionError("OS downgrade not supported. KeyStore sealed data exists on platform < M!"); + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.SealedData.fromString(serializedEncryptedSecret); + return new DatabaseSecret(KeyStoreHelper.unseal(encryptedSecret)); + } + } + + private static @NonNull DatabaseSecret createAndStoreDatabaseSecret(@NonNull Context context, int accountId) { + SecureRandom random = new SecureRandom(); + byte[] secret = new byte[32]; + random.nextBytes(secret); + + DatabaseSecret databaseSecret = new DatabaseSecret(secret); + + if (Build.VERSION.SDK_INT >= 23) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes()); + Prefs.setDatabaseEncryptedSecret(context, encryptedSecret.serialize(), accountId); + } else { + Prefs.setDatabaseUnencryptedSecret(context, databaseSecret.asString(), accountId); + } + + return databaseSecret; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java b/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..5a957c36838783f2483fb2410d4e44e383ea4949 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableEntryException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +public final class KeyStoreHelper { + + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "DeltaSecret"; + + @RequiresApi(Build.VERSION_CODES.M) + public static SealedData seal(@NonNull byte[] input) { + SecretKey secretKey = getOrCreateKeyStoreEntry(); + + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + + byte[] iv = cipher.getIV(); + byte[] data = cipher.doFinal(input); + + return new SealedData(iv, data); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + public static byte[] unseal(@NonNull SealedData sealedData) { + SecretKey secretKey = getKeyStoreEntry(); + + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); + + return cipher.doFinal(sealedData.data); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey getOrCreateKeyStoreEntry() { + if (hasKeyStoreEntry()) return getKeyStoreEntry(); + else return createKeyStoreEntry(); + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey createKeyStoreEntry() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); + KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(); + + keyGenerator.init(keyGenParameterSpec); + + return keyGenerator.generateKey(); + } catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey getKeyStoreEntry() { + KeyStore keyStore = getKeyStore(); + + try { + // Attempt 1 + return getSecretKey(keyStore); + } catch (UnrecoverableKeyException e) { + try { + // Attempt 2 + return getSecretKey(keyStore); + } catch (UnrecoverableKeyException e2) { + throw new AssertionError(e2); + } + } + } + + private static SecretKey getSecretKey(KeyStore keyStore) throws UnrecoverableKeyException { + try { + KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry) keyStore.getEntry(KEY_ALIAS, null); + return entry.getSecretKey(); + } catch (UnrecoverableKeyException e) { + throw e; + } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableEntryException e) { + throw new AssertionError(e); + } + } + + private static KeyStore getKeyStore() { + try { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEY_STORE); + keyStore.load(null); + return keyStore; + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static boolean hasKeyStoreEntry() { + try { + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + + return ks.containsAlias(KEY_ALIAS) && ks.entryInstanceOf(KEY_ALIAS, KeyStore.SecretKeyEntry.class); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + throw new AssertionError(e); + } + } + + public static class SealedData { + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] iv; + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] data; + + SealedData(@NonNull byte[] iv, @NonNull byte[] data) { + this.iv = iv; + this.data = data; + } + + @SuppressWarnings("unused") + public SealedData() { /* needed by JsonUtils.fromJson() */ } + + public String serialize() { + try { + return JsonUtils.toJson(this); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static SealedData fromString(@NonNull String value) { + try { + return JsonUtils.fromJson(value, SealedData.class); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING)); + } + } + + private static class ByteArrayDeserializer extends JsonDeserializer { + + @Override + public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING); + } + } + + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/Address.java b/src/main/java/org/thoughtcrime/securesms/database/Address.java new file mode 100644 index 0000000000000000000000000000000000000000..a114946a70db76f89a7b46ccc40585b7ef25587b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/Address.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.database; + + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +public class Address implements Parcelable, Comparable

{ + + public static final Parcelable.Creator
CREATOR = new Parcelable.Creator
() { + public Address createFromParcel(Parcel in) { + return new Address(in); + } + + public Address[] newArray(int size) { + return new Address[size]; + } + }; + + public static final Address UNKNOWN = new Address("Unknown"); + + private final static String DC_CHAT_PREFIX = "dc:"; + private final static String DC_CONTACT_PREFIX = "dcc:"; + + private final String address; + + public static Address fromChat(int chatId) { + return new Address(DC_CHAT_PREFIX + chatId); + } + + public static Address fromContact(int contactId) { + return new Address(DC_CONTACT_PREFIX + contactId); + } + + private Address(@NonNull String address) { + if (address == null) throw new AssertionError(address); + this.address = address; + } + + public Address(Parcel in) { + this(in.readString()); + } + + public static @NonNull Address fromSerialized(@NonNull String serialized) { + return new Address(serialized); + } + + public boolean isDcChat() { return address.startsWith(DC_CHAT_PREFIX); }; + + public boolean isDcContact() { return address.startsWith(DC_CONTACT_PREFIX); }; + + public int getDcChatId() { + if(!isDcChat()) throw new AssertionError("Not dc chat: " + address); + return Integer.valueOf(address.substring(DC_CHAT_PREFIX.length())); + } + + public int getDcContactId() { + if(!isDcContact()) throw new AssertionError("Not dc contact: " + address); + return Integer.valueOf(address.substring(DC_CONTACT_PREFIX.length())); + } + + @Override + public String toString() { + return address; + } + + public String serialize() { + return address; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof Address)) return false; + return address.equals(((Address) other).address); + } + + @Override + public int hashCode() { + return address.hashCode(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(address); + } + + @Override + public int compareTo(@NonNull Address other) { + return address.compareTo(other.address); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java new file mode 100644 index 0000000000000000000000000000000000000000..95cbb3bfaa7415c83cd51500df54a12f318f93af --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +public class AttachmentDatabase { + public static final int TRANSFER_PROGRESS_DONE = 0; + public static final int TRANSFER_PROGRESS_STARTED = 1; + public static final int TRANSFER_PROGRESS_FAILED = 3; +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..e517390ca6c166241bc1448b755cc85bf649432e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java @@ -0,0 +1,237 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +/** + * RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView. + */ +public abstract class CursorRecyclerViewAdapter extends RecyclerView.Adapter { + private final Context context; + private final DataSetObserver observer = new AdapterDataSetObserver(); + + @VisibleForTesting static final int HEADER_TYPE = Integer.MIN_VALUE; + @VisibleForTesting static final int FOOTER_TYPE = Integer.MIN_VALUE + 1; + @VisibleForTesting static final long HEADER_ID = Long.MIN_VALUE; + @VisibleForTesting static final long FOOTER_ID = Long.MIN_VALUE + 1; + + private Cursor cursor; + private boolean valid; + private @Nullable View header; + private @Nullable View footer; + + private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder { + public HeaderFooterViewHolder(View itemView) { + super(itemView); + } + } + + protected CursorRecyclerViewAdapter(Context context, Cursor cursor) { + this.context = context; + this.cursor = cursor; + if (cursor != null) { + valid = true; + cursor.registerDataSetObserver(observer); + } + } + + protected @NonNull Context getContext() { + return context; + } + + public @Nullable Cursor getCursor() { + return cursor; + } + + public boolean hasHeaderView() { + return header != null; + } + + public boolean hasFooterView() { + return footer != null; + } + + public void changeCursor(Cursor cursor) { + Cursor old = swapCursor(cursor); + if (old != null) { + old.close(); + } + } + + public Cursor swapCursor(Cursor newCursor) { + if (newCursor == cursor) { + return null; + } + + final Cursor oldCursor = cursor; + if (oldCursor != null) { + oldCursor.unregisterDataSetObserver(observer); + } + + cursor = newCursor; + if (cursor != null) { + cursor.registerDataSetObserver(observer); + } + + valid = cursor != null; + notifyDataSetChanged(); + return oldCursor; + } + + @Override + public int getItemCount() { + if (!isActiveCursor()) return 0; + + return cursor.getCount() + + getFastAccessSize() + + (hasHeaderView() ? 1 : 0) + + (hasFooterView() ? 1 : 0); + } + + @SuppressWarnings("unchecked") + @Override + public final void onViewRecycled(@NonNull ViewHolder holder) { + if (!(holder instanceof HeaderFooterViewHolder)) { + onItemViewRecycled((VH)holder); + } + } + + public void onItemViewRecycled(VH holder) {} + + @NonNull + @Override + public final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case HEADER_TYPE: return new HeaderFooterViewHolder(header); + case FOOTER_TYPE: return new HeaderFooterViewHolder(footer); + default: return onCreateItemViewHolder(parent, viewType); + } + } + + public abstract VH onCreateItemViewHolder(ViewGroup parent, int viewType); + + @SuppressWarnings("unchecked") + @Override + public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (!isHeaderPosition(position) && !isFooterPosition(position)) { + if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position); + else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position)); + } + } + + public abstract void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor); + + protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) { + + } + + @Override + public final int getItemViewType(int position) { + if (isHeaderPosition(position)) return HEADER_TYPE; + if (isFooterPosition(position)) return FOOTER_TYPE; + if (isFastAccessPosition(position)) return getFastAccessItemViewType(position); + return getItemViewType(getCursorAtPositionOrThrow(position)); + } + + public int getItemViewType(@NonNull Cursor cursor) { + return 0; + } + + @Override + public final long getItemId(int position) { + if (isHeaderPosition(position)) return HEADER_ID; + if (isFooterPosition(position)) return FOOTER_ID; + if (isFastAccessPosition(position)) return getFastAccessItemId(position); + long itemId = getItemId(getCursorAtPositionOrThrow(position)); + return itemId <= Long.MIN_VALUE + 1 ? itemId + 2 : itemId; + } + + public long getItemId(@NonNull Cursor cursor) { + return cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + } + + protected @NonNull Cursor getCursorAtPositionOrThrow(final int position) { + if (!isActiveCursor()) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + if (!cursor.moveToPosition(getCursorPosition(position))) { + throw new IllegalStateException("couldn't move cursor to position " + position); + } + return cursor; + } + + protected boolean isActiveCursor() { + return valid && cursor != null; + } + + protected boolean isFooterPosition(int position) { + return hasFooterView() && position == getItemCount() - 1; + } + + protected boolean isHeaderPosition(int position) { + return hasHeaderView() && position == 0; + } + + private int getCursorPosition(int position) { + if (hasHeaderView()) { + position -= 1; + } + + return position - getFastAccessSize(); + } + + protected int getFastAccessItemViewType(int position) { + return 0; + } + + protected boolean isFastAccessPosition(int position) { + return false; + } + + protected long getFastAccessItemId(int position) { + return 0; + } + + protected int getFastAccessSize() { + return 0; + } + + private class AdapterDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + valid = true; + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + valid = false; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java b/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..ee60b0d7922462af5e114eeda59aa495b9994ae6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java @@ -0,0 +1,286 @@ +package org.thoughtcrime.securesms.database.loaders; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.loader.content.AsyncTaskLoader; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class BucketedThreadMediaLoader extends AsyncTaskLoader { + + private final int chatId; + private final int msgType1; + private final int msgType2; + private final int msgType3; + + public BucketedThreadMediaLoader(@NonNull Context context, int chatId, int msgType1, int msgType2, int msgType3) { + super(context); + this.chatId = chatId; + this.msgType1 = msgType1; + this.msgType2 = msgType2; + this.msgType3 = msgType3; + + onContentChanged(); + } + + @Override + protected void onStartLoading() { + if (takeContentChanged()) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onAbandon() { + } + + @Override + public BucketedThreadMedia loadInBackground() { + BucketedThreadMedia result = new BucketedThreadMedia(getContext()); + DcContext context = DcHelper.getContext(getContext()); + if(chatId!=-1 /*0=all, -1=none*/) { + int[] messages = context.getChatMedia(chatId, msgType1, msgType2, msgType3); + for(int nextId : messages) { + result.add(context.getMsg(nextId)); + } + } + + return result; + } + + public static class BucketedThreadMedia { + + private final TimeBucket TODAY; + private final TimeBucket YESTERDAY; + private final TimeBucket THIS_WEEK; + private final TimeBucket LAST_WEEK; + private final TimeBucket THIS_MONTH; + private final TimeBucket LAST_MONTH; + private final MonthBuckets OLDER; + + private final TimeBucket[] TIME_SECTIONS; + + public BucketedThreadMedia(@NonNull Context context) { + // from today midnight until the end of human time + this.TODAY = new TimeBucket(context.getString(R.string.today), + addToCalendarFromTodayMidnight(Calendar.DAY_OF_YEAR, 0), Long.MAX_VALUE); + // from yesterday midnight until today midnight + this.YESTERDAY = new TimeBucket(context.getString(R.string.yesterday), + addToCalendarFromTodayMidnight(Calendar.DAY_OF_YEAR, -1), TODAY.startTime); + // from the closest start of week until yesterday midnight (that can be a negative timespace and thus be empty) + this.THIS_WEEK = new TimeBucket(context.getString(R.string.this_week), + setInCalendarFromTodayMidnight(Calendar.DAY_OF_WEEK, getCalendar().getFirstDayOfWeek()), YESTERDAY.startTime); + // from the closest start of week one week back until the closest start of week. + this.LAST_WEEK = new TimeBucket(context.getString(R.string.last_week), + addToCalendarFrom(THIS_WEEK.startTime, Calendar.WEEK_OF_YEAR, -1), THIS_WEEK.startTime); + // from the closest 1st of a month until one week prior to the closest start of week (can be negative and thus empty) + this.THIS_MONTH = new TimeBucket(context.getString(R.string.this_month), + setInCalendarFromTodayMidnight(Calendar.DAY_OF_MONTH, 1), LAST_WEEK.startTime); + // from the closest 1st of a month, one month back to the closest 1st of a month + this.LAST_MONTH = new TimeBucket(context.getString(R.string.last_month), + addToCalendarFrom(THIS_MONTH.startTime, Calendar.MONTH, -1), LAST_WEEK.startTime); + this.TIME_SECTIONS = new TimeBucket[]{TODAY, YESTERDAY, THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH}; + this.OLDER = new MonthBuckets(); + } + + public void add(DcMsg imageMessage) { + for (TimeBucket timeSection : TIME_SECTIONS) { + if (timeSection.inRange(imageMessage.getTimestamp())) { + timeSection.add(imageMessage); + return; + } + } + OLDER.add(imageMessage); + } + + public LinkedList getAll() { + LinkedList messages = new LinkedList<>(); + for (TimeBucket section : TIME_SECTIONS) { + messages.addAll(section.records); + } + for (List records : OLDER.months.values()) { + messages.addAll(records); + } + return messages; + } + + public int getSectionCount() { + int count = 0; + for (TimeBucket section : TIME_SECTIONS) { + if (!section.isEmpty()) count++; + } + return count + OLDER.getSectionCount(); + } + + public int getSectionItemCount(int section) { + List activeTimeBuckets = new ArrayList<>(); + for (TimeBucket bucket : TIME_SECTIONS) { + if (!bucket.isEmpty()) activeTimeBuckets.add(bucket); + } + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItemCount(); + else return OLDER.getSectionItemCount(section - activeTimeBuckets.size()); + } + + public DcMsg get(int section, int item) { + List activeTimeBuckets = new ArrayList<>(); + for (TimeBucket bucket : TIME_SECTIONS) { + if (!bucket.isEmpty()) activeTimeBuckets.add(bucket); + } + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItem(item); + else return OLDER.getItem(section - activeTimeBuckets.size(), item); + } + + public String getName(int section) { + List activeTimeBuckets = new ArrayList<>(); + for (TimeBucket bucket : TIME_SECTIONS) { + if (!bucket.isEmpty()) activeTimeBuckets.add(bucket); + } + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getName(); + else return OLDER.getName(section - activeTimeBuckets.size()); + } + + // tests should override this function to deliver a preset calendar. + Calendar getCalendar() { + return Calendar.getInstance(); + } + + long setInCalendarFromTodayMidnight(int field, int amount) { + Calendar calendar = getCalendar(); + setCalendarToTodayMidnight(calendar); + calendar.set(field, amount); + return calendar.getTimeInMillis(); + } + + long addToCalendarFrom(long relativeTo, int field, int amount) { + Calendar calendar = getCalendar(); + calendar.setTime(new Date(relativeTo)); + calendar.add(field, amount); + return calendar.getTimeInMillis(); + } + + long addToCalendarFromTodayMidnight(int field, int amount) { + Calendar calendar = getCalendar(); + setCalendarToTodayMidnight(calendar); + calendar.add(field, amount); + return calendar.getTimeInMillis(); + } + + void setCalendarToTodayMidnight(Calendar calendar) { + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + } + + private static class TimeBucket { + + private final LinkedList records = new LinkedList<>(); + + private final long startTime; + private final long endTime; + private final String name; + + TimeBucket(String name, long startTime, long endTime) { + this.name = name; + this.startTime = startTime; + this.endTime = endTime; + } + + void add(DcMsg record) { + this.records.addFirst(record); + } + + boolean inRange(long timestamp) { + return timestamp >= startTime && timestamp < endTime; + } + + boolean isEmpty() { + return records.isEmpty(); + } + + int getItemCount() { + return records.size(); + } + + DcMsg getItem(int position) { + return records.get(position); + } + + String getName() { + return name; + } + } + + private static class MonthBuckets { + + private final Map> months = new HashMap<>(); + + void add(DcMsg record) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(record.getTimestamp()); + + int year = calendar.get(Calendar.YEAR) - 1900; + int month = calendar.get(Calendar.MONTH); + Date date = new Date(year, month, 1); + + if (months.containsKey(date)) { + months.get(date).addFirst(record); + } else { + LinkedList list = new LinkedList<>(); + list.add(record); + months.put(date, list); + } + } + + int getSectionCount() { + return months.size(); + } + + int getSectionItemCount(int section) { + return months.get(getSection(section)).size(); + } + + DcMsg getItem(int section, int position) { + return months.get(getSection(section)).get(position); + } + + Date getSection(int section) { + ArrayList keys = new ArrayList<>(months.keySet()); + Collections.sort(keys, Collections.reverseOrder()); + + return keys.get(section); + } + + String getName(int section) { + Date sectionDate = getSection(section); + + return new SimpleDateFormat("MMMM yyyy", Util.getLocale()).format(sectionDate); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java b/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..24f366cc370750d3dd20aee008cbeaef3874cff2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.database.loaders; + + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMediaGalleryElement; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.AsyncLoader; + +public class PagingMediaLoader extends AsyncLoader { + + private static final String TAG = PagingMediaLoader.class.getSimpleName(); + + private final DcMsg msg; + private final boolean leftIsRecent; + + public PagingMediaLoader(@NonNull Context context, @NonNull DcMsg msg, boolean leftIsRecent) { + super(context); + this.msg = msg; + this.leftIsRecent = leftIsRecent; + } + + @Nullable + @Override + public DcMediaGalleryElement loadInBackground() { + DcContext context = DcHelper.getContext(getContext()); + int[] mediaMessages = context.getChatMedia(msg.getChatId(), DcMsg.DC_MSG_IMAGE, DcMsg.DC_MSG_GIF, DcMsg.DC_MSG_VIDEO); + // first id is the oldest message. + int currentIndex = -1; + for(int ii = 0; ii < mediaMessages.length; ii++) { + if(mediaMessages[ii] == msg.getId()) { + currentIndex = ii; + break; + } + } + if(currentIndex == -1) { + currentIndex = 0; + DcMsg unfound = context.getMsg(msg.getId()); + Log.e(TAG, "did not find message in list: " + unfound.getId() + " / " + unfound.getFile() + " / " + unfound.getText()); + } + return new DcMediaGalleryElement(mediaMessages, currentIndex, context, leftIsRecent); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..ebbdad799a57b7359178bf496404a0f135ee1506 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.database.loaders; + + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; + +import androidx.loader.content.CursorLoader; + +import org.thoughtcrime.securesms.permissions.Permissions; + +public class RecentPhotosLoader extends CursorLoader { + + public static final Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + + private static final String[] PROJECTION = new String[] { + MediaStore.Images.ImageColumns._ID, + MediaStore.Images.ImageColumns.DATE_TAKEN, + MediaStore.Images.ImageColumns.DATE_MODIFIED, + MediaStore.Images.ImageColumns.ORIENTATION, + MediaStore.Images.ImageColumns.MIME_TYPE + }; + + private static final String SELECTION = Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.IS_PENDING + " != 1" + : null; + + private final Context context; + + public RecentPhotosLoader(Context context) { + super(context); + this.context = context.getApplicationContext(); + } + + @Override + public Cursor loadInBackground() { + if (Permissions.hasAll(context, Permissions.galleryPermissions())) { + return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + PROJECTION, SELECTION, null, + MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); + } else { + return null; + } + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java new file mode 100644 index 0000000000000000000000000000000000000000..c41e31d763f26e5fe4adcc61ba28240a1f12b1fb --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.StyleSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcLot; + +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * The message record model which represents thread heading messages. + * + * @author Moxie Marlinspike + * + */ +public class ThreadRecord { + + private final Recipient recipient; + private final long dateReceived; + private final long threadId; + private final String body; + + private final int unreadCount; + private final int visibility; + private final boolean isSendingLocations; + private final boolean isMuted; + private final boolean isContactRequest; + private @Nullable final DcLot dcSummary; + + public ThreadRecord(@NonNull String body, + @NonNull Recipient recipient, long dateReceived, int unreadCount, + long threadId, + int visibility, + boolean isSendingLocations, + boolean isMuted, + boolean isContactRequest, + @Nullable DcLot dcSummary) + { + this.threadId = threadId; + this.recipient = recipient; + this.dateReceived = dateReceived; + this.body = body; + this.unreadCount = unreadCount; + this.visibility = visibility; + this.isSendingLocations = isSendingLocations; + this.isMuted = isMuted; + this.isContactRequest = isContactRequest; + this.dcSummary = dcSummary; + } + + public @NonNull String getBody() { + return body; + } + + public Recipient getRecipient() { + return recipient; + } + + public long getDateReceived() { + return dateReceived; + } + + public long getThreadId() { + return threadId; + } + + public SpannableString getDisplayBody() { + if(dcSummary!=null && dcSummary.getText1Meaning()==DcLot.DC_TEXT1_DRAFT) { + String draftText = dcSummary.getText1() + ":"; + return emphasisAdded(draftText + " " + dcSummary.getText2(), 0, draftText.length()); + } else { + return new SpannableString(getBody()); + } + } + + private SpannableString emphasisAdded(String sequence, int start, int end) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), + start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public int getUnreadCount() { + return unreadCount; + } + + public long getDate() { + return getDateReceived(); + } + + public int getVisibility() { + return visibility; + } + + public boolean isSendingLocations() { + return isSendingLocations; + } + + public boolean isMuted() { + return isMuted; + } + + public boolean isContactRequest() { + return isContactRequest; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java new file mode 100644 index 0000000000000000000000000000000000000000..23ce38eb4ef9b9eb2594d606237b75ab50f4e4e1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.location.Location; +import android.util.Log; + +import java.util.Observable; + +public class DcLocation extends Observable { + private static final String TAG = DcLocation.class.getSimpleName(); + private Location lastLocation; + private static DcLocation instance; + private static final int TIMEOUT = 1000 * 15; + private static final int EARTH_RADIUS = 6371; + + private DcLocation() { + lastLocation = getDefault(); + } + + public static DcLocation getInstance() { + if (instance == null) { + instance = new DcLocation(); + } + return instance; + } + + public Location getLastLocation() { + return lastLocation; + } + + + public boolean isValid() { + return !"?".equals(lastLocation.getProvider()); + } + + void updateLocation(Location location) { + if (isBetterLocation(location, lastLocation)) { + lastLocation = location; + + instance.setChanged(); + instance.notifyObservers(); + } + } + + void reset() { + updateLocation(getDefault()); + + } + + private Location getDefault() { + return new Location("?"); + } + + /** https://developer.android.com/guide/topics/location/strategies + * Determines whether one Location reading is better than the current Location fix + * @param location The new Location that you want to evaluate + * @param currentBestLocation The current Location fix, to which you want to compare the new one + */ + private boolean isBetterLocation(Location location, Location currentBestLocation) { + if (currentBestLocation == null) { + // A new location is always better than no location + return true; + } + + // Check whether the new location fix is newer or older + long timeDelta = location.getTime() - currentBestLocation.getTime(); + boolean isSignificantlyOlder = timeDelta < -TIMEOUT; + + if (isSignificantlyOlder) { + return false; + } + + // Check whether the new location fix is more or less accurate + int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy()); + Log.d(TAG, "accuracyDelta: " + accuracyDelta); + boolean isSignificantlyMoreAccurate = accuracyDelta > 50; + boolean isSameProvider = isSameProvider(location.getProvider(), currentBestLocation.getProvider()); + + if (isSignificantlyMoreAccurate && isSameProvider) { + return true; + } + + boolean isMoreAccurate = accuracyDelta > 0; + double distance = distance(location, currentBestLocation); + return hasLocationChanged(distance) && isMoreAccurate || + hasLocationSignificantlyChanged(distance); + + } + + private boolean hasLocationSignificantlyChanged(double distance) { + return distance > 30D; + } + + private boolean hasLocationChanged(double distance) { + return distance > 10D; + } + + private double distance(Location location, Location currentBestLocation) { + + double startLat = location.getLatitude(); + double startLong = location.getLongitude(); + double endLat = currentBestLocation.getLatitude(); + double endLong = currentBestLocation.getLongitude(); + + double dLat = Math.toRadians(endLat - startLat); + double dLong = Math.toRadians(endLong - startLong); + + startLat = Math.toRadians(startLat); + endLat = Math.toRadians(endLat); + + double a = haversin(dLat) + Math.cos(startLat) * Math.cos(endLat) * haversin(dLong); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + double distance = EARTH_RADIUS * c * 1000; + Log.d(TAG, "Distance between location updates: " + distance); + return distance; + } + + private double haversin(double val) { + return Math.pow(Math.sin(val / 2), 2); + } + + /** Checks whether two providers are the same */ + private boolean isSameProvider(String provider1, String provider2) { + if (provider1 == null) { + return provider2 == null; + } + return provider1.equals(provider2); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java new file mode 100644 index 0000000000000000000000000000000000000000..1876caa2cf6f4fac436c6afe0794288053e46f1b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.location.Location; +import android.os.IBinder; +import android.util.Log; + +import org.thoughtcrime.securesms.connect.DcHelper; + +import java.util.LinkedList; +import java.util.Observable; +import java.util.Observer; + +import static android.content.Context.BIND_AUTO_CREATE; + +public class DcLocationManager implements Observer { + + private static final String TAG = DcLocationManager.class.getSimpleName(); + private LocationBackgroundService.LocationBackgroundServiceBinder serviceBinder; + private final Context context; + private DcLocation dcLocation = DcLocation.getInstance(); + private final LinkedList pendingShareLastLocation = new LinkedList<>(); + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.d(TAG, "background service connected"); + serviceBinder = (LocationBackgroundService.LocationBackgroundServiceBinder) service; + while (!pendingShareLastLocation.isEmpty()) { + shareLastLocation(pendingShareLastLocation.pop()); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.d(TAG, "background service disconnected"); + serviceBinder = null; + } + }; + + public DcLocationManager(Context context) { + this.context = context.getApplicationContext(); + DcLocation.getInstance().addObserver(this); + if (DcHelper.getContext(context).isSendingLocationsToChat(0)) { + startLocationEngine(); + } + } + + public void startLocationEngine() { + if (serviceBinder == null) { + Intent intent = new Intent(context.getApplicationContext(), LocationBackgroundService.class); + context.bindService(intent, serviceConnection, BIND_AUTO_CREATE); + } + } + + public void stopLocationEngine() { + if (serviceBinder == null) { + return; + } + context.unbindService(serviceConnection); + serviceBinder.stop(); + serviceBinder = null; + } + + public void stopSharingLocation(int chatId) { + DcHelper.getContext(context).sendLocationsToChat(chatId, 0); + if(!DcHelper.getContext(context).isSendingLocationsToChat(0)) { + stopLocationEngine(); + } + } + + public void shareLocation(int duration, int chatId) { + startLocationEngine(); + Log.d(TAG, String.format("Share location in chat %d for %d seconds", chatId, duration)); + DcHelper.getContext(context).sendLocationsToChat(chatId, duration); + if (dcLocation.isValid()) { + writeDcLocationUpdateMessage(); + } + } + + public void shareLastLocation(int chatId) { + if (serviceBinder == null) { + pendingShareLastLocation.push(chatId); + startLocationEngine(); + return; + } + + if (dcLocation.isValid()) { + DcHelper.getContext(context).sendLocationsToChat(chatId, 1); + writeDcLocationUpdateMessage(); + } + } + + @Override + public void update(Observable o, Object arg) { + if (o instanceof DcLocation) { + dcLocation = (DcLocation) o; + if (dcLocation.isValid()) { + writeDcLocationUpdateMessage(); + } + } + } + + private void writeDcLocationUpdateMessage() { + Log.d(TAG, "Share location: " + dcLocation.getLastLocation().getLatitude() + ", " + dcLocation.getLastLocation().getLongitude()); + Location lastLocation = dcLocation.getLastLocation(); + + boolean continueLocationStreaming = DcHelper.getContext(context).setLocation((float) lastLocation.getLatitude(), (float) lastLocation.getLongitude(), lastLocation.getAccuracy()); + if (!continueLocationStreaming) { + stopLocationEngine(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java new file mode 100644 index 0000000000000000000000000000000000000000..4b17f1d279d6a6dd75026056e10e611e0db47ffe --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; + +public class LocationBackgroundService extends Service { + + private static final int INITIAL_TIMEOUT = 1000 * 60 * 2; + private static final String TAG = LocationBackgroundService.class.getSimpleName(); + private LocationManager locationManager = null; + private static final int LOCATION_INTERVAL = 1000; + private static final float LOCATION_DISTANCE = 25F; + ServiceLocationListener locationListener; + + private final IBinder mBinder = new LocationBackgroundServiceBinder(); + + @Override + public boolean bindService(Intent service, ServiceConnection conn, int flags) { + return super.bindService(service, conn, flags); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + locationManager = (LocationManager) getApplicationContext().getSystemService(Context.LOCATION_SERVICE); + if (locationManager == null) { + Log.e(TAG, "Unable to initialize location service"); + return; + } + + locationListener = new ServiceLocationListener(); + Location lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (lastLocation != null) { + long locationAge = System.currentTimeMillis() - lastLocation.getTime(); + if (locationAge <= 600 * 1000) { // not older than 10 minutes + DcLocation.getInstance().updateLocation(lastLocation); + } + } + //requestLocationUpdate(LocationManager.NETWORK_PROVIDER); + requestLocationUpdate(LocationManager.GPS_PROVIDER); + initialLocationUpdate(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (locationManager == null) { + return; + } + + try { + locationManager.removeUpdates(locationListener); + } catch (Exception ex) { + Log.i(TAG, "fail to remove location listeners, ignore", ex); + } + } + + private void requestLocationUpdate(String provider) { + try { + locationManager.requestLocationUpdates( + provider, LOCATION_INTERVAL, LOCATION_DISTANCE, + locationListener); + } catch (SecurityException | IllegalArgumentException ex) { + Log.e(TAG, String.format("Unable to request %s provider based location updates.", provider), ex); + } + } + + private void initialLocationUpdate() { + try { + Location gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (gpsLocation != null && System.currentTimeMillis() - gpsLocation.getTime() < INITIAL_TIMEOUT) { + locationListener.onLocationChanged(gpsLocation); + } + + } catch (NullPointerException | SecurityException e) { + e.printStackTrace(); + } + } + + class LocationBackgroundServiceBinder extends Binder { + LocationBackgroundServiceBinder getService() { + return LocationBackgroundServiceBinder.this; + } + + void stop() { + DcLocation.getInstance().reset(); + stopSelf(); + } + } + + private class ServiceLocationListener implements LocationListener { + + @Override + public void onLocationChanged(@NonNull Location location) { + Log.d(TAG, "onLocationChanged: " + location); + if (location == null) { + return; + } + DcLocation.getInstance().updateLocation(location); + } + + @Override + public void onProviderDisabled(@NonNull String provider) { + Log.e(TAG, "onProviderDisabled: " + provider); + } + + @Override + public void onProviderEnabled(@NonNull String provider) { + Log.e(TAG, "onProviderEnabled: " + provider); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + Log.e(TAG, "onStatusChanged: " + provider + " status: " + status); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java b/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java new file mode 100644 index 0000000000000000000000000000000000000000..56db549e702570e7f8895ea71f665c97236e64a4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.glide; + + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; + +import java.io.IOException; +import java.io.InputStream; + +class ContactPhotoFetcher implements DataFetcher { + + private final Context context; + private final ContactPhoto contactPhoto; + + private InputStream inputStream; + + ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { + this.context = context.getApplicationContext(); + this.contactPhoto = contactPhoto; + } + + @Override + public void loadData(@NonNull Priority priority, DataCallback callback) { + try { + inputStream = contactPhoto.openInputStream(context); + callback.onDataReady(inputStream); + } catch (IOException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + try { + if (inputStream != null) inputStream.close(); + } catch (IOException ignored) {} + } + + @Override + public void cancel() { + + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java b/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..cfe867ce4bf09c1000d27b0fb6d035ca8605e75f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.glide; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; + +import java.io.InputStream; + +public class ContactPhotoLoader implements ModelLoader { + + private final Context context; + + private ContactPhotoLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData buildLoadData(@NonNull ContactPhoto contactPhoto, int width, int height, @NonNull Options options) { + return new LoadData<>(contactPhoto, new ContactPhotoFetcher(context, contactPhoto)); + } + + @Override + public boolean handles(@NonNull ContactPhoto contactPhoto) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + + private final Context context; + + public Factory(Context context) { + this.context = context.getApplicationContext(); + } + + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new ContactPhotoLoader(context); + } + + @Override + public void teardown() {} + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDecoder.java b/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDecoder.java new file mode 100644 index 0000000000000000000000000000000000000000..c425f6694f15e8bc2ae0f297c7626850648380c6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDecoder.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.glide.lottie; + +import androidx.annotation.NonNull; + +import com.airbnb.lottie.LottieComposition; +import com.airbnb.lottie.LottieCompositionFactory; +import com.airbnb.lottie.LottieResult; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + +public class LottieDecoder implements ResourceDecoder { + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) { + return true; + } + + public Resource decode( + @NonNull InputStream source, int width, int height, @NonNull Options options) + throws IOException { + try { + LottieResult result = LottieCompositionFactory.fromJsonInputStreamSync(new GZIPInputStream(source), null); + return new SimpleResource<>(result.getValue()); + } catch (Exception ex) { + throw new IOException("Cannot load Lottie animation from stream", ex); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDrawableTranscoder.java b/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDrawableTranscoder.java new file mode 100644 index 0000000000000000000000000000000000000000..39ffaf0208dfa9b5069940886fa6a79cb4ea69b2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/lottie/LottieDrawableTranscoder.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.glide.lottie; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieComposition; +import com.airbnb.lottie.LottieDrawable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; + +public class LottieDrawableTranscoder implements ResourceTranscoder { + @Nullable + @Override + public Resource transcode( + @NonNull Resource toTranscode, @NonNull Options options) { + LottieComposition composition = toTranscode.get(); + LottieDrawable drawable = new LottieDrawable(); + drawable.setComposition(composition); + drawable.setSafeMode(true); + drawable.setRepeatCount(LottieDrawable.INFINITE); + return new SimpleResource<>(drawable); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDecoder.java b/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDecoder.java new file mode 100644 index 0000000000000000000000000000000000000000..c530c97d781aba366d86712005e93bcb8d298684 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDecoder.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.glide.svg; + +import static com.bumptech.glide.request.target.Target.SIZE_ORIGINAL; + +import androidx.annotation.NonNull; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGParseException; +import java.io.IOException; +import java.io.InputStream; + +/** Decodes an SVG internal representation from an {@link InputStream}. */ +public class SvgDecoder implements ResourceDecoder { + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) { + // TODO: Can we tell? + return true; + } + + public Resource decode( + @NonNull InputStream source, int width, int height, @NonNull Options options) + throws IOException { + try { + SVG svg = SVG.getFromInputStream(source); + if (width != SIZE_ORIGINAL) { + svg.setDocumentWidth(width); + } + if (height != SIZE_ORIGINAL) { + svg.setDocumentHeight(height); + } + return new SimpleResource<>(svg); + } catch (SVGParseException ex) { + throw new IOException("Cannot load SVG from stream", ex); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDrawableTranscoder.java b/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDrawableTranscoder.java new file mode 100644 index 0000000000000000000000000000000000000000..b545ad6334cb81975194514f077db1d9c53eaf83 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/glide/svg/SvgDrawableTranscoder.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.glide.svg; + +import android.graphics.Picture; +import android.graphics.drawable.PictureDrawable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; +import com.caverock.androidsvg.SVG; + +/** + * Convert the {@link SVG}'s internal representation to an Android-compatible one ({@link Picture}). + */ +public class SvgDrawableTranscoder implements ResourceTranscoder { + @Nullable + @Override + public Resource transcode( + @NonNull Resource toTranscode, @NonNull Options options) { + SVG svg = toTranscode.get(); + Picture picture = svg.renderToPicture(); + PictureDrawable drawable = new PictureDrawable(picture); + return new SimpleResource<>(drawable); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java new file mode 100644 index 0000000000000000000000000000000000000000..95437c920cd28a8871957487a77ea227f08958f3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * The local extent of a {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}. + * i.e. all {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}s have a bounding rectangle from: + *

+ * {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}. + */ +public final class Bounds { + + public static final float LEFT = -1000f; + public static final float RIGHT = 1000f; + + public static final float TOP = -1000f; + public static final float BOTTOM = 1000f; + + public static final float CENTRE_X = (LEFT + RIGHT) / 2f; + public static final float CENTRE_Y = (TOP + BOTTOM) / 2f; + + public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y }; + + private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP, + Bounds.RIGHT, Bounds.TOP, + Bounds.RIGHT, Bounds.BOTTOM, + Bounds.LEFT, Bounds.BOTTOM }; + + static RectF newFullBounds() { + return new RectF(LEFT, TOP, RIGHT, BOTTOM); + } + + public static final RectF FULL_BOUNDS = newFullBounds(); + + public static boolean contains(float x, float y) { + return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right && + y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom; + } + + /** + * Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds. + * + * @param matrix matrix to transform points by, null is treated as identity. + * @return true iff all points remain in bounds after transformation. + */ + public static boolean boundsRemainInBounds(@Nullable Matrix matrix) { + if (matrix == null) return true; + + float[] dst = new float[POINTS.length]; + + matrix.mapPoints(dst, POINTS); + + return allWithinBounds(dst); + } + + private static boolean allWithinBounds(@NonNull float[] points) { + boolean allHit = true; + + for (int i = 0; i < points.length / 2; i++) { + float x = points[2 * i]; + float y = points[2 * i + 1]; + + if (!Bounds.contains(x, y)) { + allHit = false; + break; + } + } + + return allHit; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java new file mode 100644 index 0000000000000000000000000000000000000000..7b12689d6b572966ef724cd5a0b0a3c54ba35114 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; + +import androidx.annotation.NonNull; + +/** + * Tracks the current matrix for a canvas. + *

+ * This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}. + * {@link Canvas#getMatrix()} provides this hint in its documentation: + * "track relevant transform state outside of the canvas." + *

+ * To achieve this, any changes to the canvas matrix must be done via this class, including save and + * restore operations where the matrix was altered in between. + */ +public final class CanvasMatrix { + + private final static int STACK_HEIGHT_LIMIT = 16; + + private final Canvas canvas; + private final Matrix canvasMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT]; + private int stackHeight; + + CanvasMatrix(Canvas canvas) { + this.canvas = canvas; + for (int i = 0; i < stack.length; i++) { + stack[i] = new Matrix(); + } + } + + public void concat(@NonNull Matrix matrix) { + canvas.concat(matrix); + canvasMatrix.preConcat(matrix); + } + + void save() { + canvas.save(); + if (stackHeight == STACK_HEIGHT_LIMIT) { + throw new AssertionError("Not enough space on stack"); + } + stack[stackHeight++].set(canvasMatrix); + } + + void restore() { + canvas.restore(); + canvasMatrix.set(stack[--stackHeight]); + } + + void getCurrent(@NonNull Matrix into) { + into.set(canvasMatrix); + } + + public void setToIdentity() { + if (canvasMatrix.invert(temp)) { + concat(temp); + } + } + + public void initial(Matrix viewMatrix) { + concat(viewMatrix); + } + + boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void mapPoints(float[] dst, float[] src) { + canvasMatrix.mapPoints(dst, src); + } + + public void copyTo(@NonNull Matrix matrix) { + matrix.set(canvasMatrix); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..11706dfc027ea4d83439d852f28b0ae5f46833bf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.imageeditor; + +import androidx.annotation.ColorInt; + +/** + * A renderer that can have its color changed. + *

+ * For example, Lines and Text can change color. + */ +public interface ColorableRenderer extends Renderer { + + @ColorInt + int getColor(); + + void setColor(@ColorInt int color); +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java new file mode 100644 index 0000000000000000000000000000000000000000..7fe7c0dd6f891c8848b48532f69b9cce5cc2551b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; + +/** + * Passes touch events into a {@link BezierDrawingRenderer}. + */ +class DrawingSession extends ElementEditSession { + + private final BezierDrawingRenderer renderer; + + private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) { + super(selected, inverseMatrix); + this.renderer = renderer; + } + + public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) { + DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer); + drawingSession.setScreenStartPoint(0, point); + renderer.setFirstPoint(drawingSession.startPointElement[0]); + return drawingSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + if (p != 0) return; + setScreenEndPoint(p, point); + renderer.addNewPoint(endPointElement[0]); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return this; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java new file mode 100644 index 0000000000000000000000000000000000000000..3ea2148c771eec464375c434813886dcb488621d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +/** + * Represents an underway edit of the image. + *

+ * Accepts new touch positions, new touch points, released touch points and when complete can commit the edit. + *

+ * Examples of edit session implementations are, Drag, Draw, Resize: + *

+ * {@link ElementDragEditSession} for dragging with a single finger. + * {@link ElementScaleEditSession} for resize/dragging with two fingers. + * {@link DrawingSession} for drawing with a single finger. + */ +interface EditSession { + + void movePoint(int p, @NonNull PointF point); + + EditorElement getSelected(); + + EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p); + + EditSession removePoint(@NonNull Matrix newInverse, int p); + + void commit(); +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java new file mode 100644 index 0000000000000000000000000000000000000000..23bd5e96148dd92cecfb2c74d0389b5dad0d126b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementDragEditSession extends ElementEditSession { + + private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + selected.getEditorMatrix() + .setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return ElementScaleEditSession.startScale(this, newInverse, point, p); + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return this; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java new file mode 100644 index 0000000000000000000000000000000000000000..6bf0e229d1cfd6446de35fafb38e1cf0c26eb85e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +abstract class ElementEditSession implements EditSession { + + private final Matrix inverseMatrix; + + final EditorElement selected; + + final PointF[] startPointElement = newTwoPointArray(); + final PointF[] endPointElement = newTwoPointArray(); + final PointF[] startPointScreen = newTwoPointArray(); + final PointF[] endPointScreen = newTwoPointArray(); + + ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + this.selected = selected; + this.inverseMatrix = inverseMatrix; + } + + void setScreenStartPoint(int p, @NonNull PointF point) { + startPointScreen[p] = point; + mapPoint(startPointElement[p], inverseMatrix, point); + } + + void setScreenEndPoint(int p, @NonNull PointF point) { + endPointScreen[p] = point; + mapPoint(endPointElement[p], inverseMatrix, point); + } + + @Override + public abstract void movePoint(int p, @NonNull PointF point); + + @Override + public void commit() { + selected.commitEditorMatrix(); + } + + @Override + public EditorElement getSelected() { + return selected; + } + + private static PointF[] newTwoPointArray() { + PointF[] array = new PointF[2]; + for (int i = 0; i < array.length; i++) { + array[i] = new PointF(); + } + return array; + } + + /** + * Map src to dst using the matrix. + * + * @param dst Output point. + * @param matrix Matrix to transform point with. + * @param src Input point. + */ + private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) { + float[] in = { src.x, src.y }; + float[] out = new float[2]; + matrix.mapPoints(out, in); + dst.set(out[0], out[1]); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java new file mode 100644 index 0000000000000000000000000000000000000000..d18934b3d4bfc19c75113b2fc7a987b0a03b7e00 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementScaleEditSession extends ElementEditSession { + + private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) { + session.commit(); + ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix); + newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenStartPoint(p, point); + newSession.setScreenEndPoint(p, point); + return newSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + if (selected.getFlags().isAspectLocked()) { + + float scale = (float) findScale(startPointElement, endPointElement); + + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + editorMatrix.postScale(scale, scale); + + double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]); + + if (!selected.getFlags().isRotateLocked()) { + editorMatrix.postRotate((float) Math.toDegrees(angle)); + } + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } else { + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + + float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x); + float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y); + + editorMatrix.postScale(scaleX, scaleY); + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return convertToDrag(p, newInverse); + } + + private static double angle(@NonNull PointF a, @NonNull PointF b) { + return Math.atan2(a.y - b.y, a.x - b.x); + } + + private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) { + return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]); + } + + /** + * Find relative distance between an old and new set of Points. + * + * @param from Pair of points. + * @param to New pair of points. + * @return Scale + */ + private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) { + float originalD2 = getDistanceSquared(from[0], from[1]); + float newD2 = getDistanceSquared(to[0], to[1]); + return Math.sqrt(newD2 / originalD2); + } + + /** + * Distance between two points squared. + */ + private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) { + float dx = a.x - b.x; + float dy = a.y - b.y; + return dx * dx + dy * dy; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java new file mode 100644 index 0000000000000000000000000000000000000000..32a2fc51efeab42a07e08c33f3cc0055a511f912 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Rect; +import android.text.InputType; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +/** + * Invisible {@link android.widget.EditText} that is used during in-image text editing. + */ +final class HiddenEditText extends AppCompatEditText { + + @SuppressLint("InlinedApi") + private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; + + @Nullable + private EditorElement currentTextEditorElement; + + @Nullable + private MultiLineTextRenderer currentTextEntity; + + @Nullable + private Runnable onEndEdit; + + @Nullable + private OnEditOrSelectionChange onEditOrSelectionChange; + + public HiddenEditText(Context context) { + super(context); + setAlpha(0); + setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START)); + setClickable(false); + setFocusable(true); + setFocusableInTouchMode(true); + setBackgroundColor(Color.TRANSPARENT); + setTextSize(TypedValue.COMPLEX_UNIT_SP, 1); + setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + clearFocus(); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { + super.onTextChanged(text, start, lengthBefore, lengthAfter); + if (currentTextEntity != null) { + currentTextEntity.setText(text.toString()); + postEditOrSelectionChange(); + } + } + + @Override + public void onEditorAction(int actionCode) { + super.onEditorAction(actionCode); + if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) { + currentTextEntity.setFocused(false); + endEdit(); + } + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(focused, direction, previouslyFocusedRect); + if (currentTextEntity != null) { + currentTextEntity.setFocused(focused); + if (!focused) { + endEdit(); + } + } + } + + private void endEdit() { + if (onEndEdit != null) { + onEndEdit.run(); + } + } + + private void postEditOrSelectionChange() { + if (currentTextEditorElement != null && currentTextEntity != null && onEditOrSelectionChange != null) { + onEditOrSelectionChange.onChange(currentTextEditorElement, currentTextEntity); + } + } + + @Nullable MultiLineTextRenderer getCurrentTextEntity() { + return currentTextEntity; + } + + @Nullable EditorElement getCurrentTextEditorElement() { + return currentTextEditorElement; + } + + public void setCurrentTextEditorElement(@Nullable EditorElement currentTextEditorElement) { + if (currentTextEditorElement != null && currentTextEditorElement.getRenderer() instanceof MultiLineTextRenderer) { + this.currentTextEditorElement = currentTextEditorElement; + setCurrentTextEntity((MultiLineTextRenderer) currentTextEditorElement.getRenderer()); + } else { + this.currentTextEditorElement = null; + setCurrentTextEntity(null); + } + + postEditOrSelectionChange(); + } + + private void setCurrentTextEntity(@Nullable MultiLineTextRenderer currentTextEntity) { + if (this.currentTextEntity != currentTextEntity) { + if (this.currentTextEntity != null) { + this.currentTextEntity.setFocused(false); + } + this.currentTextEntity = currentTextEntity; + if (currentTextEntity != null) { + String text = currentTextEntity.getText(); + setText(text); + setSelection(text.length()); + } else { + setText(""); + } + } + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + super.onSelectionChanged(selStart, selEnd); + if (currentTextEntity != null) { + currentTextEntity.setSelection(selStart, selEnd); + postEditOrSelectionChange(); + } + } + + @Override + public boolean requestFocus(int direction, Rect previouslyFocusedRect) { + boolean focus = super.requestFocus(direction, previouslyFocusedRect); + + if (currentTextEntity != null && focus) { + currentTextEntity.setFocused(true); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT); + if (!imm.isAcceptingText()) { + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + } + + return focus; + } + + public void hideKeyboard() { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) { + setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME + : getImeOptions() & ~INCOGNITO_KEYBOARD_IME); + } + + public void setOnEndEdit(@Nullable Runnable onEndEdit) { + this.onEndEdit = onEndEdit; + } + + public void setOnEditOrSelectionChange(@Nullable OnEditOrSelectionChange onEditOrSelectionChange) { + this.onEditOrSelectionChange = onEditOrSelectionChange; + } + + public interface OnEditOrSelectionChange { + void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorMediaConstraints.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorMediaConstraints.java new file mode 100644 index 0000000000000000000000000000000000000000..0eab881ee270778209290a330d90b4a495761fff --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorMediaConstraints.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; + +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.util.Util; + +public class ImageEditorMediaConstraints extends MediaConstraints { + + private static final int MAX_IMAGE_DIMEN_LOWMEM = 768; + private static final int MAX_IMAGE_DIMEN = 1536; + private static final int KB = 1024; + private static final int MB = 1024 * KB; + + @Override + public int getImageMaxWidth(Context context) { + return Util.isLowMemory(context) ? MAX_IMAGE_DIMEN_LOWMEM : MAX_IMAGE_DIMEN; + } + + @Override + public int getImageMaxHeight(Context context) { + return getImageMaxWidth(context); + } + + @Override + public int getImageMaxSize(Context context) { + return 4 * MB; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java new file mode 100644 index 0000000000000000000000000000000000000000..fcdc2f13a835036c70c50f6b042994088adfae7a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -0,0 +1,473 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.widget.FrameLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.GestureDetectorCompat; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +/** + * ImageEditorView + *

+ * Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and + * addition and manipulation of text/drawing/and other image layers that move with the base image. + *

+ * Drawing + *

+ * Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}. + * Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}. + *

+ * New images + *

+ * To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)} + * which centers the new item in the current crop area. + */ +public final class ImageEditorView extends FrameLayout { + + private HiddenEditText editText; + + @NonNull + private Mode mode = Mode.MoveAndResize; + + @ColorInt + private int color = 0xff000000; + + private float thickness = 0.02f; + + @NonNull + private Paint.Cap cap = Paint.Cap.ROUND; + + private EditorModel model; + + private GestureDetectorCompat doubleTap; + + @Nullable + private DrawingChangedListener drawingChangedListener; + + @Nullable + private UndoRedoStackListener undoRedoStackListener; + + private final Matrix viewMatrix = new Matrix(); + private final RectF viewPort = Bounds.newFullBounds(); + private final RectF visibleViewPort = Bounds.newFullBounds(); + private final RectF screen = new RectF(); + + private TapListener tapListener; + private RendererContext rendererContext; + + @Nullable + private EditSession editSession; + private boolean moreThanOnePointerUsedInSession; + + public ImageEditorView(Context context) { + super(context); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setWillNotDraw(false); + setModel(new EditorModel()); + + editText = createAHiddenTextEntryField(); + + doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener()); + + setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event)); + } + + private HiddenEditText createAHiddenTextEntryField() { + HiddenEditText editText = new HiddenEditText(getContext()); + addView(editText); + editText.clearFocus(); + editText.setOnEndEdit(this::doneTextEditing); + editText.setOnEditOrSelectionChange(this::zoomToFitText); + return editText; + } + + public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled); + editText.setCurrentTextEditorElement(editorElement); + if (selectAll) { + editText.selectAll(); + } + editText.requestFocus(); + } + } + + private void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) { + getModel().zoomToTextElement(editorElement, textRenderer); + } + + public boolean isTextEditing() { + return editText.getCurrentTextEntity() != null; + } + + public void doneTextEditing() { + getModel().zoomOut(); + if (editText.getCurrentTextEntity() != null) { + editText.setCurrentTextEditorElement(null); + editText.hideKeyboard(); + if (tapListener != null) { + tapListener.onEntityDown(null); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (rendererContext == null || rendererContext.canvas != canvas) { + rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate); + } + rendererContext.save(); + try { + rendererContext.canvasMatrix.initial(viewMatrix); + + model.draw(rendererContext, editText.getCurrentTextEditorElement()); + } finally { + rendererContext.restore(); + } + } + + private final RendererContext.Ready rendererReady = new RendererContext.Ready() { + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + model.onReady(renderer, cropMatrix, size); + invalidate(); + } + }; + + private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate(); + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateViewMatrix(); + } + + private void updateViewMatrix() { + screen.right = getWidth(); + screen.bottom = getHeight(); + + viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL); + + float[] values = new float[9]; + viewMatrix.getValues(values); + + float scale = values[0] / values[4]; + + RectF tempViewPort = Bounds.newFullBounds(); + if (scale < 1) { + tempViewPort.top /= scale; + tempViewPort.bottom /= scale; + } else { + tempViewPort.left *= scale; + tempViewPort.right *= scale; + } + + visibleViewPort.set(tempViewPort); + + viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER); + + model.setVisibleViewPort(visibleViewPort); + + invalidate(); + } + + public void setModel(@NonNull EditorModel model) { + if (this.model != model) { + if (this.model != null) { + this.model.setInvalidate(null); + this.model.setUndoRedoStackListener(null); + } + this.model = model; + this.model.setInvalidate(this::invalidate); + this.model.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); + this.model.setVisibleViewPort(visibleViewPort); + invalidate(); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + Matrix inverse = new Matrix(); + PointF point = getPoint(event); + EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse); + + moreThanOnePointerUsedInSession = false; + model.pushUndoPoint(); + editSession = startEdit(inverse, point, selected); + + if (tapListener != null && allowTaps()) { + if (editSession != null) { + tapListener.onEntityDown(editSession.getSelected()); + } else { + tapListener.onEntityDown(null); + } + } + + return true; + } + case MotionEvent.ACTION_MOVE: { + if (editSession != null) { + int historySize = event.getHistorySize(); + int pointerCount = Math.min(2, event.getPointerCount()); + + for (int h = 0; h < historySize; h++) { + for (int p = 0; p < pointerCount; p++) { + editSession.movePoint(p, getHistoricalPoint(event, p, h)); + } + } + + for (int p = 0; p < pointerCount; p++) { + editSession.movePoint(p, getPoint(event, p)); + } + model.moving(editSession.getSelected()); + invalidate(); + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_DOWN: { + if (editSession != null && event.getPointerCount() == 2) { + moreThanOnePointerUsedInSession = true; + editSession.commit(); + model.pushUndoPoint(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex()); + } else { + editSession = null; + } + if (editSession == null) { + dragDropRelease(); + } + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_UP: { + if (editSession != null && event.getActionIndex() < 2) { + editSession.commit(); + model.pushUndoPoint(); + dragDropRelease(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.removePoint(newInverse, event.getActionIndex()); + } else { + editSession = null; + } + return true; + } + break; + } + case MotionEvent.ACTION_UP: { + if (editSession != null) { + editSession.commit(); + dragDropRelease(); + + editSession = null; + model.postEdit(moreThanOnePointerUsedInSession); + invalidate(); + return true; + } else { + model.postEdit(moreThanOnePointerUsedInSession); + } + break; + } + } + + return super.onTouchEvent(event); + } + + private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + if (mode == Mode.Draw || mode == Mode.Blur) { + return startADrawingSession(point); + } else { + return startAMoveAndResizeSession(inverse, point, selected); + } + } + + private EditSession startADrawingSession(@NonNull PointF point) { + BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot()); + EditorElement element = new EditorElement(renderer, mode == Mode.Blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING); + model.addElementCentered(element, 1); + + Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix); + + return DrawingSession.start(element, renderer, elementInverseMatrix, point); + } + + private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + Matrix elementInverseMatrix; + if (selected == null) return null; + + if (selected.getRenderer() instanceof ThumbRenderer) { + ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer(); + + selected = getModel().findById(thumb.getElementToControl()); + + if (selected == null) return null; + + elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix); + if (elementInverseMatrix != null) { + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point); + } else { + return null; + } + } + + return ElementDragEditSession.startDrag(selected, inverse, point); + } + + public void setMode(@NonNull Mode mode) { + this.mode = mode; + } + + public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) { + this.thickness = thickness; + this.cap = cap; + setMode(blur ? Mode.Blur : Mode.Draw); + } + + public void setDrawingBrushColor(int color) { + this.color = color; + } + + private void dragDropRelease() { + model.dragDropRelease(); + if (drawingChangedListener != null) { + drawingChangedListener.onDrawingChanged(); + } + } + + private static PointF getPoint(MotionEvent event) { + return getPoint(event, 0); + } + + private static PointF getPoint(MotionEvent event, int p) { + return new PointF(event.getX(p), event.getY(p)); + } + + private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) { + return new PointF(event.getHistoricalX(p, historicalIndex), + event.getHistoricalY(p, historicalIndex)); + } + + public EditorModel getModel() { + return model; + } + + public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) { + this.drawingChangedListener = drawingChangedListener; + } + + public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + } + + public void setTapListener(TapListener tapListener) { + this.tapListener = tapListener; + } + + public void deleteElement(@Nullable EditorElement editorElement) { + if (editorElement != null) { + model.pushUndoPoint(); + model.delete(editorElement); + invalidate(); + } + } + + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + if (undoRedoStackListener != null) { + undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable); + } + } + + private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { + + @Override + public boolean onDoubleTap(MotionEvent e) { + if (tapListener != null && editSession != null && allowTaps()) { + tapListener.onEntityDoubleTap(editSession.getSelected()); + } + return true; + } + + @Override + public void onLongPress(MotionEvent e) {} + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (tapListener != null && allowTaps()) { + if (editSession != null) { + EditorElement selected = editSession.getSelected(); + model.indicateSelected(selected); + tapListener.onEntitySingleTap(selected); + } else { + tapListener.onEntitySingleTap(null); + } + } + return true; + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + } + + private boolean allowTaps() { + return !model.isCropping() && mode != Mode.Draw && mode != Mode.Blur; + } + + public enum Mode { + MoveAndResize, + Draw, + Blur + } + + public interface DrawingChangedListener { + void onDrawingChanged(); + } + + public interface TapListener { + + void onEntityDown(@Nullable EditorElement editorElement); + + void onEntitySingleTap(@Nullable EditorElement editorElement); + + void onEntityDoubleTap(@NonNull EditorElement editorElement); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java new file mode 100644 index 0000000000000000000000000000000000000000..9b431e45fda1787c0de82269213250fa9d8387b8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +/** + * Responsible for rendering a single {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement} to the canvas. + *

+ * Because it knows the most about the whereabouts of the image it is also responsible for hit detection. + */ +public interface Renderer extends Parcelable { + + /** + * Draw self to the context. + * + * @param rendererContext The context to draw to. + */ + void render(@NonNull RendererContext rendererContext); + + /** + * @param x Local coordinate X + * @param y Local coordinate Y + * @return true iff hit. + */ + boolean hitTest(float x, float y); +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java new file mode 100644 index 0000000000000000000000000000000000000000..f814699f1841a05a19225a3442c4262eeb48bba9 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +import java.util.Collections; +import java.util.List; + +/** + * Contains all of the information required for a {@link Renderer} to do its job. + *

+ * Includes a {@link #canvas}, preconfigured with the correct matrix. + *

+ * The {@link #canvasMatrix} should further matrix manipulation be required. + */ +public final class RendererContext { + + @NonNull + public final Context context; + + @NonNull + public final Canvas canvas; + + @NonNull + public final CanvasMatrix canvasMatrix; + + @NonNull + public final Ready rendererReady; + + @NonNull + public final Invalidate invalidate; + + private boolean blockingLoad; + + private float fade = 1f; + + private boolean isEditing = true; + + private List children = Collections.emptyList(); + private Paint maskPaint; + + public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) { + this.context = context; + this.canvas = canvas; + this.canvasMatrix = new CanvasMatrix(canvas); + this.rendererReady = rendererReady; + this.invalidate = invalidate; + } + + public void setBlockingLoad(boolean blockingLoad) { + this.blockingLoad = blockingLoad; + } + + /** + * {@link Renderer}s generally run in the foreground but can load any data they require in the background. + *

+ * If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn. + *

+ * However, when isBlockingLoad is true, the renderer is running in the background for the final render + * and must load the data immediately and block the render until done so. + */ + public boolean isBlockingLoad() { + return blockingLoad; + } + + public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void setIsEditing(boolean isEditing) { + this.isEditing = isEditing; + } + + public boolean isEditing() { + return isEditing; + } + + public void setFade(float fade) { + this.fade = fade; + } + + public int getAlpha(int alpha) { + return Math.max(0, Math.min(255, (int) (fade * alpha))); + } + + /** + * Persist the current state on to a stack, must be complimented by a call to {@link #restore()}. + */ + public void save() { + canvasMatrix.save(); + } + + /** + * Restore the current state from the stack, must match a call to {@link #save()}. + */ + public void restore() { + canvasMatrix.restore(); + } + + public void getCurrent(@NonNull Matrix into) { + canvasMatrix.getCurrent(into); + } + + public void setChildren(@NonNull List children) { + this.children = children; + } + + public @NonNull List getChildren() { + return children; + } + + public void setMaskPaint(@Nullable Paint maskPaint) { + this.maskPaint = maskPaint; + } + + public @Nullable Paint getMaskPaint() { + return maskPaint; + } + + public interface Ready { + + Ready NULL = (renderer, cropMatrix, size) -> { + }; + + void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size); + } + + public interface Invalidate { + + Invalidate NULL = (renderer) -> { + }; + + void onInvalidate(@NonNull Renderer renderer); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java new file mode 100644 index 0000000000000000000000000000000000000000..860883e56891307ee6809bf734c81b2c58253717 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; + +class ThumbDragEditSession extends ElementEditSession { + + @NonNull + private final ThumbRenderer.ControlPoint controlPoint; + + private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + this.controlPoint = controlPoint; + } + + static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + float x = controlPoint.opposite().getX(); + float y = controlPoint.opposite().getY(); + + float dx = endPointElement[0].x - startPointElement[0].x; + float dy = endPointElement[0].y - startPointElement[0].y; + + float xEnd = controlPoint.getX() + dx; + float yEnd = controlPoint.getY() + dy; + + boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter(); + + float defaultScale = aspectLocked ? 2 : 1; + + float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (xEnd - x) / (controlPoint.getX() - x); + float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (yEnd - y) / (controlPoint.getY() - y); + + scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite()); + } + + private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) { + float x = around.getX(); + float y = around.getY(); + editorMatrix.postTranslate(-x, -y); + if (aspectLocked) { + float minScale = Math.min(scaleX, scaleY); + editorMatrix.postScale(minScale, minScale); + } else { + editorMatrix.postScale(scaleX, scaleY); + } + editorMatrix.postTranslate(x, y); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return null; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return null; + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java new file mode 100644 index 0000000000000000000000000000000000000000..6f7b5f11c957a0560e7859540682d05126053f2f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.imageeditor; + +public interface UndoRedoStackListener { + + void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable); +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java new file mode 100644 index 0000000000000000000000000000000000000000..6aae9f88a18dc73ad70a7aad8f5c9453e0a73c0a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import androidx.annotation.Nullable; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +final class AlphaAnimation { + + private final static Interpolator interpolator = new LinearInterpolator(); + + final static AlphaAnimation NULL_1 = new AlphaAnimation(1); + + private final float from; + private final float to; + private final Runnable invalidate; + private final boolean canAnimate; + private float animatedFraction; + + private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) { + this.from = from; + this.to = to; + this.invalidate = invalidate; + this.canAnimate = invalidate != null; + } + + private AlphaAnimation(float fixed) { + this(fixed, fixed, null); + } + + static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return new AlphaAnimation(to); + } + + if (from != to) { + AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate); + animationMatrix.start(); + return animationMatrix; + } else { + return new AlphaAnimation(to); + } + } + + private void start() { + if (canAnimate && invalidate != null) { + ValueAnimator animator = ValueAnimator.ofFloat(from, to); + animator.setDuration(200); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + float getValue() { + if (!canAnimate) return to; + + return animatedFraction; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java new file mode 100644 index 0000000000000000000000000000000000000000..202821cc38a443f2f0878d9dc35ef51194d410d4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.view.animation.CycleInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; + +import org.thoughtcrime.securesms.imageeditor.CanvasMatrix; + +/** + * Animation Matrix provides a matrix that animates over time down to the identity matrix. + */ +final class AnimationMatrix { + + private final static float[] iValues = new float[9]; + private final static Interpolator interpolator = new DecelerateInterpolator(); + private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f)); + + static final AnimationMatrix NULL = new AnimationMatrix(); + + static { + new Matrix().getValues(iValues); + } + + private final Runnable invalidate; + private final boolean canAnimate; + private final float[] undoValues = new float[9]; + + private final Matrix temp = new Matrix(); + private final float[] tempValues = new float[9]; + + private ValueAnimator animator; + private float animatedFraction; + + private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) { + this.invalidate = invalidate; + this.canAnimate = true; + undo.getValues(undoValues); + } + + private AnimationMatrix() { + canAnimate = false; + invalidate = null; + } + + static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + Matrix undo = new Matrix(); + boolean inverted = to.invert(undo); + if (inverted) { + undo.preConcat(from); + } + if (inverted && !undo.isIdentity()) { + AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate); + animationMatrix.start(interpolator); + return animationMatrix; + } else { + return NULL; + } + } + + /** + * Animate applying a matrix and then animate removing. + */ + static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate); + animationMatrix.start(pulseInterpolator); + + return animationMatrix; + } + + private void start(@NonNull Interpolator interpolator) { + if (canAnimate) { + animator = ValueAnimator.ofFloat(1, 0); + animator.setDuration(250); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + void stop() { + ValueAnimator animator = this.animator; + if (animator != null) animator.cancel(); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull Matrix onTo) { + if (!canAnimate) return; + + onTo.preConcat(buildTemp()); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) { + if (!canAnimate) return; + + canvasMatrix.concat(buildTemp()); + } + + private Matrix buildTemp() { + if (!canAnimate) { + temp.reset(); + return temp; + } + + final float fractionCompliment = 1f - animatedFraction; + for (int i = 0; i < 9; i++) { + tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i]; + } + + temp.setValues(tempValues); + return temp; + } + + private static Interpolator inverse(@NonNull Interpolator interpolator) { + return input -> 1f - interpolator.getInterpolation(input); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java new file mode 100644 index 0000000000000000000000000000000000000000..35f3115b0af9eeaeedefec113b7a274599a347b4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class Bisect { + + static final float ACCURACY = 0.001f; + + private static final int MAX_ITERATIONS = 16; + + interface Predicate { + boolean test(); + } + + interface ModifyElement { + void applyFactor(@NonNull Matrix matrix, float factor); + } + + /** + * Given a predicate function, attempts to finds the boundary between predicate true and predicate false. + * If it returns true, it will animate the element to the closest true value found to that boundary. + * + * @param element The element to modify. + * @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate. + * @param atMost A value believed to be in bounds. + * @param predicate The out of bounds predicate. + * @param modifyElement Apply the latest value to the element local matrix. + * @param invalidate For animation if finds a result. + * @return true iff finds a result. + */ + static boolean bisectToTest(@NonNull EditorElement element, + float outOfBoundsValue, + float atMost, + @NonNull Predicate predicate, + @NonNull ModifyElement modifyElement, + @NonNull Runnable invalidate) + { + Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement); + + if (closestSuccesful != null) { + element.animateLocalTo(closestSuccesful, invalidate); + return true; + } else { + return false; + } + } + + /** + * Given a predicate function, attempts to finds the boundary between predicate true and predicate false. + * Returns new local matrix for the element if a solution is found. + * + * @param element The element to modify. + * @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate. + * @param atMost A value believed to be in bounds. + * @param predicate The out of bounds predicate. + * @param modifyElement Apply the latest value to the element local matrix. + * @return matrix to replace local matrix iff finds a result, null otherwise. + */ + static @Nullable Matrix bisectToTest(@NonNull EditorElement element, + float outOfBoundsValue, + float atMost, + @NonNull Predicate predicate, + @NonNull ModifyElement modifyElement) + { + Matrix elementMatrix = element.getLocalMatrix(); + Matrix original = new Matrix(elementMatrix); + Matrix closestSuccessful = new Matrix(); + boolean haveResult = false; + int attempt = 0; + float successValue = 0; + float inBoundsValue = atMost; + float nextValueToTry = inBoundsValue; + + do { + attempt++; + + modifyElement.applyFactor(elementMatrix, nextValueToTry); + try { + + if (predicate.test()) { + inBoundsValue = nextValueToTry; + + // if first success or closer to out of bounds than the current closest + if (!haveResult || Math.abs(nextValueToTry - outOfBoundsValue) < Math.abs(successValue - outOfBoundsValue)) { + haveResult = true; + successValue = nextValueToTry; + closestSuccessful.set(elementMatrix); + } + } else { + if (attempt == 1) { + // failure on first attempt means inBoundsValue is actually out of bounds and so no solution + return null; + } + outOfBoundsValue = nextValueToTry; + } + } finally { + // reset + elementMatrix.set(original); + } + + nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f; + + } while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY); + + if (haveResult) { + return closestSuccessful; + } + return null; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..34f3a8b0bb5ebafe2e93d90992f53e09ddabc130 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.UUID; + +/** + * Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen. + *

+ * Does not draw anything. + */ +class CropThumbRenderer implements Renderer, ThumbRenderer { + + private final ControlPoint controlPoint; + private final UUID toControl; + + private final float[] centreOnScreen = new float[2]; + private final Matrix matrix = new Matrix(); + private int size; + + CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) { + this.controlPoint = controlPoint; + this.toControl = toControl; + } + + @Override + public ControlPoint getControlPoint() { + return controlPoint; + } + + @Override + public UUID getElementToControl() { + return toControl; + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE); + rendererContext.canvasMatrix.copyTo(matrix); + size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size); + } + + @Override + public boolean hitTest(float x, float y) { + float[] hitPointOnScreen = new float[2]; + matrix.mapPoints(hitPointOnScreen, new float[]{ x, y }); + + float dx = centreOnScreen[0] - hitPointOnScreen[0]; + float dy = centreOnScreen[1] - hitPointOnScreen[1]; + + return dx * dx + dy * dy < size * size; + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropThumbRenderer createFromParcel(Parcel in) { + return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in)); + } + + @Override + public CropThumbRenderer[] newArray(int size) { + return new CropThumbRenderer[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(controlPoint.ordinal()); + ParcelUtils.writeUUID(dest, toControl); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java new file mode 100644 index 0000000000000000000000000000000000000000..26d10d8556f9da69dee677426678dab1c819ee3a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java @@ -0,0 +1,354 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * An image consists of a tree of {@link EditorElement}s. + *

+ * Each element has some persisted state: + * - An optional {@link Renderer} so that it can draw itself. + * - A list of child elements that make the tree possible. + * - Its own transformation matrix, which applies to itself and all its children. + * - A set of flags controlling visibility, selectablity etc. + *

+ * Then some temporary state. + * - A editor matrix for displaying as yet uncommitted edits. + * - An animation matrix for animating from one matrix to another. + * - Deleted children to allow them to fade out on delete. + * - Temporary flags, for temporary visibility, selectablity etc. + */ +public final class EditorElement implements Parcelable { + + private static final Comparator Z_ORDER_COMPARATOR = (e1, e2) -> Integer.compare(e1.zOrder, e2.zOrder); + + private final UUID id; + private final EditorFlags flags; + private final Matrix localMatrix = new Matrix(); + private final Matrix editorMatrix = new Matrix(); + private final int zOrder; + + @Nullable + private final Renderer renderer; + + private final Matrix temp = new Matrix(); + + private final Matrix tempMatrix = new Matrix(); + + private final List children = new LinkedList<>(); + private final List deletedChildren = new LinkedList<>(); + + @NonNull + private AnimationMatrix animationMatrix = AnimationMatrix.NULL; + + @NonNull + private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1; + + public EditorElement(@Nullable Renderer renderer) { + this(renderer, 0); + } + + public EditorElement(@Nullable Renderer renderer, int zOrder) { + this.id = UUID.randomUUID(); + this.flags = new EditorFlags(); + this.renderer = renderer; + this.zOrder = zOrder; + } + + private EditorElement(Parcel in) { + id = ParcelUtils.readUUID(in); + flags = new EditorFlags(in.readInt()); + ParcelUtils.readMatrix(localMatrix, in); + renderer = in.readParcelable(Renderer.class.getClassLoader()); + zOrder = in.readInt(); + in.readTypedList(children, EditorElement.CREATOR); + } + + UUID getId() { + return id; + } + + public @Nullable Renderer getRenderer() { + return renderer; + } + + /** + * Iff Visible, + * Renders tree with the following localMatrix: + *

+ * viewModelMatrix * localMatrix * editorMatrix * animationMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix + * + * @param rendererContext Canvas to draw on to. + */ + public void draw(@NonNull RendererContext rendererContext) { + if (!flags.isVisible() && !flags.isChildrenVisible()) return; + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(localMatrix); + + if (rendererContext.isEditing()) { + rendererContext.canvasMatrix.concat(editorMatrix); + animationMatrix.preConcatValueTo(rendererContext.canvasMatrix); + } + + if (flags.isVisible()) { + float alpha = alphaAnimation.getValue(); + if (alpha > 0) { + rendererContext.setFade(alpha); + rendererContext.setChildren(children); + drawSelf(rendererContext); + rendererContext.setFade(1f); + } + } + + if (flags.isChildrenVisible()) { + drawChildren(children, rendererContext); + drawChildren(deletedChildren, rendererContext); + } + + rendererContext.restore(); + } + + private void drawSelf(@NonNull RendererContext rendererContext) { + if (renderer == null) return; + renderer.render(rendererContext); + } + + private static void drawChildren(@NonNull List children, @NonNull RendererContext rendererContext) { + for (EditorElement element : children) { + if (element.zOrder >= 0) { + element.draw(rendererContext); + } + } + } + + public void addElement(@NonNull EditorElement element) { + children.add(element); + Collections.sort(children, Z_ORDER_COMPARATOR); + } + + public Matrix getLocalMatrix() { + return localMatrix; + } + + public Matrix getEditorMatrix() { + return editorMatrix; + } + + EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element); + } + + EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) { + final float[] dst = new float[2]; + final float[] src = { x, y }; + + return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> { + Renderer renderer = element.renderer; + if (renderer == null) return false; + inverseMatrix.mapPoints(dst, src); + return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]); + }); + } + + public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) { + temp.set(viewModelMatrix); + + temp.preConcat(localMatrix); + temp.preConcat(editorMatrix); + + if (temp.invert(tempMatrix)) { + + for (int i = children.size() - 1; i >= 0; i--) { + EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate); + if (elementAt != null) { + return elementAt; + } + } + + if (predicate.test(this, tempMatrix)) { + outInverseModelMatrix.set(tempMatrix); + return this; + } + } + + return null; + } + + public EditorFlags getFlags() { + return flags; + } + + int getChildCount() { + return children.size(); + } + + EditorElement getChild(int i) { + return children.get(i); + } + + void forAllInTree(@NonNull PerElementFunction function) { + function.apply(this); + for (EditorElement child : children) { + child.forAllInTree(function); + } + } + + void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) { + Iterator iterator = children.iterator(); + while (iterator.hasNext()) { + if (iterator.next() == editorElement) { + iterator.remove(); + addDeletedChildFadingOut(editorElement, invalidate); + } + } + } + + void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) { + deletedChildren.add(fromElement); + fromElement.animateFadeOut(invalidate); + } + + private void animateFadeOut(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(1, 0, invalidate); + } + + void animateFadeIn(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(0, 1, invalidate); + } + + @Nullable EditorElement parentOf(@NonNull EditorElement element) { + if (children.contains(element)) { + return this; + } + for (EditorElement child : children) { + EditorElement parent = child.parentOf(element); + if (parent != null) { + return parent; + } + } + return null; + } + + public void singleScalePulse(@Nullable Runnable invalidate) { + Matrix scale = new Matrix(); + scale.setScale(1.2f, 1.2f); + + animationMatrix = AnimationMatrix.singlePulse(scale, invalidate); + } + + public int getZOrder() { + return zOrder; + } + + public interface PerElementFunction { + void apply(EditorElement element); + } + + public interface FindElementPredicate { + boolean test(EditorElement element, Matrix inverseMatrix); + } + + public void commitEditorMatrix() { + if (flags.isEditable()) { + localMatrix.preConcat(editorMatrix); + editorMatrix.reset(); + } else { + rollbackEditorMatrix(null); + } + } + + void rollbackEditorMatrix(@Nullable Runnable invalidate) { + animateEditorTo(new Matrix(), invalidate); + } + + void buildMap(Map map) { + map.put(id, this); + for (EditorElement child : children) { + child.buildMap(map); + } + } + + void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) { + Matrix oldMatrixCopy = new Matrix(oldMatrix); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(oldMatrixCopy); + animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate); + } + + void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate); + } + + void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate); + } + + /** + * @param destination Matrix to change + * @param source Matrix value to set + * @param invalidate Callback to allow animation + */ + private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) { + Matrix old = new Matrix(destination); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(old); + destination.set(source); + animationMatrix = AnimationMatrix.animate(old, destination, invalidate); + } + + Matrix getLocalMatrixAnimating() { + Matrix matrix = new Matrix(localMatrix); + animationMatrix.preConcatValueTo(matrix); + return matrix; + } + + void stopAnimation() { + animationMatrix.stop(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorElement createFromParcel(Parcel in) { + return new EditorElement(in); + } + + @Override + public EditorElement[] newArray(int size) { + return new EditorElement[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + ParcelUtils.writeUUID(dest, id); + dest.writeInt(this.flags.asInt()); + ParcelUtils.writeMatrix(dest, localMatrix); + dest.writeParcelable(renderer, flags); + dest.writeInt(zOrder); + dest.writeTypedList(children); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java new file mode 100644 index 0000000000000000000000000000000000000000..cab52e3f48283c4fc02cf88d6541080442ac9504 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -0,0 +1,363 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.OvalGuideRenderer; + +/** + * Creates and handles a strict EditorElement Hierarchy. + *

+ * root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping + * | + * |- view - contains persisted adjustments for crops + * | | + * | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view + * | | + * | |- imageRoot + * | | |- mainImage + * | | |- stickers/drawings/text + * | | + * | |- overlay - always square + * | | |- imageCrop - a crop to match the aspect of the main image + * | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view + * | | | | | All children do not move/scale or rotate. + * | | | | |- blackout + * | | | | |- thumbs + * | | | | | |- Center left thumb + * | | | | | |- Center right thumb + * | | | | | |- Top center thumb + * | | | | | |- Bottom center thumb + * | | | | | |- Top left thumb + * | | | | | |- Top right thumb + * | | | | | |- Bottom left thumb + * | | | | | |- Bottom right thumb + */ +final class EditorElementHierarchy { + + static @NonNull EditorElementHierarchy create() { + return new EditorElementHierarchy(createRoot(false)); + } + + static @NonNull EditorElementHierarchy createForCircleEditing() { + return new EditorElementHierarchy(createRoot(true)); + } + + static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) { + return new EditorElementHierarchy(root); + } + + private final EditorElement root; + private final EditorElement view; + private final EditorElement flipRotate; + private final EditorElement imageRoot; + private final EditorElement overlay; + private final EditorElement imageCrop; + private final EditorElement cropEditorElement; + private final EditorElement blackout; + private final EditorElement thumbs; + + private EditorElementHierarchy(@NonNull EditorElement root) { + this.root = root; + this.view = this.root.getChild(0); + this.flipRotate = this.view.getChild(0); + this.imageRoot = this.flipRotate.getChild(0); + this.overlay = this.flipRotate.getChild(1); + this.imageCrop = this.overlay.getChild(0); + this.cropEditorElement = this.imageCrop.getChild(0); + this.blackout = this.cropEditorElement.getChild(0); + this.thumbs = this.cropEditorElement.getChild(1); + } + + private static @NonNull EditorElement createRoot(boolean circleEdit) { + EditorElement root = new EditorElement(null); + + EditorElement imageRoot = new EditorElement(null); + root.addElement(imageRoot); + + EditorElement flipRotate = new EditorElement(null); + imageRoot.addElement(flipRotate); + + EditorElement image = new EditorElement(null); + flipRotate.addElement(image); + + EditorElement overlay = new EditorElement(null); + flipRotate.addElement(overlay); + + EditorElement imageCrop = new EditorElement(null); + overlay.addElement(imageCrop); + + EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, !circleEdit)); + + cropEditorElement.getFlags() + .setRotateLocked(true) + .setAspectLocked(true) + .setSelectable(false) + .setVisible(false) + .persist(); + + imageCrop.addElement(cropEditorElement); + + EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000)); + + blackout.getFlags() + .setSelectable(false) + .setEditable(false) + .persist(); + + cropEditorElement.addElement(blackout); + + cropEditorElement.addElement(createThumbs(cropEditorElement, !circleEdit)); + + if (circleEdit) { + EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color)); + circle.getFlags().setSelectable(false) + .persist(); + + cropEditorElement.addElement(circle); + } + + return root; + } + + private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement, boolean centerThumbs) { + EditorElement thumbs = new EditorElement(null); + + thumbs.getFlags() + .setChildrenVisible(false) + .setSelectable(false) + .setVisible(false) + .persist(); + + if (centerThumbs) { + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT)); + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER)); + } + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT)); + + return thumbs; + } + + private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) { + EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId())); + + element.getFlags() + .setSelectable(false) + .persist(); + + element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY()); + + return element; + } + + EditorElement getRoot() { + return root; + } + + EditorElement getImageRoot() { + return imageRoot; + } + + /** + * The main image, null if not yet set. + */ + @Nullable EditorElement getMainImage() { + return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null; + } + + EditorElement getCropEditorElement() { + return cropEditorElement; + } + + EditorElement getImageCrop() { + return imageCrop; + } + + EditorElement getOverlay() { + return overlay; + } + + EditorElement getFlipRotate() { + return flipRotate; + } + + void startCrop(@NonNull Runnable invalidate) { + Matrix editor = new Matrix(); + float scaleInForCrop = 0.8f; + + editor.postScale(scaleInForCrop, scaleInForCrop); + root.animateEditorTo(editor, invalidate); + + cropEditorElement.getFlags() + .setVisible(true); + + blackout.getFlags() + .setVisible(false); + + thumbs.getFlags() + .setChildrenVisible(true); + + thumbs.forAllInTree(element -> element.getFlags().setSelectable(true)); + + imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false)); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + mainImage.getFlags().setSelectable(true); + } + + invalidate.run(); + } + + void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + updateViewToCrop(visibleViewPort, invalidate); + + root.rollbackEditorMatrix(invalidate); + + root.forAllInTree(element -> element.getFlags().reset()); + } + + void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + RectF dst = new RectF(); + + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + + Matrix temp = new Matrix(); + temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER); + view.animateLocalTo(temp, invalidate); + } + + private @NonNull Matrix getCropFinalMatrix() { + Matrix matrix = new Matrix(flipRotate.getLocalMatrix()); + matrix.preConcat(imageCrop.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + return matrix; + } + + /** + * Returns a matrix that maps points from the crop on to the visible image. + *

+ * i.e. if a mapped point is in bounds, then the point is on the visible image. + */ + @Nullable Matrix imageMatrixRelativeToCrop() { + EditorElement mainImage = getMainImage(); + if (mainImage == null) return null; + + Matrix matrix1 = new Matrix(imageCrop.getLocalMatrix()); + matrix1.preConcat(cropEditorElement.getLocalMatrix()); + matrix1.preConcat(cropEditorElement.getEditorMatrix()); + + Matrix matrix2 = new Matrix(mainImage.getLocalMatrix()); + matrix2.preConcat(mainImage.getEditorMatrix()); + matrix2.preConcat(imageCrop.getLocalMatrix()); + + Matrix inverse = new Matrix(); + matrix2.invert(inverse); + inverse.preConcat(matrix1); + + return inverse; + } + + void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) { + if (cropEditorElement.getFlags().isVisible()) { + updateViewToCrop(visibleViewPort, invalidate); + } + } + + RectF getCropRect() { + RectF dst = new RectF(); + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + return dst; + } + + void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + Matrix newLocal = new Matrix(flipRotate.getLocalMatrix()); + if (degrees != 0) { + newLocal.postRotate(degrees); + } + newLocal.postScale(scaleX, scaleY); + flipRotate.animateLocalTo(newLocal, invalidate); + updateViewToCrop(visibleViewPort, invalidate); + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #root} down. + */ + Matrix getMainImageFullMatrix() { + Matrix matrix = new Matrix(); + + matrix.preConcat(view.getLocalMatrix()); + matrix.preConcat(getMainImageFullMatrixFromFlipRotate()); + + return matrix; + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down. + */ + Matrix getMainImageFullMatrixFromFlipRotate() { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(imageRoot.getLocalMatrix()); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + matrix.preConcat(mainImage.getLocalMatrix()); + } + + return matrix; + } + + /** + * Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy. + * + * @param inputSize Main image size + * @return Size after applying all zooms/rotates and crops + */ + PointF getOutputSize(@NonNull Point inputSize) { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getEditorMatrix()); + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + float xScale = 1f / (xScale(mainImage.getLocalMatrix()) * xScale(mainImage.getEditorMatrix())); + matrix.preScale(xScale, xScale); + } + + float[] dst = new float[4]; + matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y }); + + float widthF = Math.abs(dst[0] - dst[2]); + float heightF = Math.abs(dst[1] - dst[3]); + + return new PointF(widthF, heightF); + } + + /** + * Extract the x scale from a matrix, which is the length of the first column. + */ + static float xScale(@NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java new file mode 100644 index 0000000000000000000000000000000000000000..77e4dfedf7222bd085f214579ea78e1de63cb1f8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import androidx.annotation.NonNull; + +/** + * Flags for an {@link EditorElement}. + *

+ * Values you set are not persisted unless you call {@link #persist()}. + *

+ * This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}. + */ +public final class EditorFlags { + + private static final int ASPECT_LOCK = 1; + private static final int ROTATE_LOCK = 2; + private static final int SELECTABLE = 4; + private static final int VISIBLE = 8; + private static final int CHILDREN_VISIBLE = 16; + private static final int EDITABLE = 32; + + private int flags; + private int markedFlags; + private int persistedFlags; + + EditorFlags() { + this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE); + } + + EditorFlags(int flags) { + this.flags = flags; + this.persistedFlags = flags; + } + + public EditorFlags setRotateLocked(boolean rotateLocked) { + setFlag(ROTATE_LOCK, rotateLocked); + return this; + } + + public boolean isRotateLocked() { + return isFlagSet(ROTATE_LOCK); + } + + public EditorFlags setAspectLocked(boolean aspectLocked) { + setFlag(ASPECT_LOCK, aspectLocked); + return this; + } + + public boolean isAspectLocked() { + return isFlagSet(ASPECT_LOCK); + } + + public EditorFlags setSelectable(boolean selectable) { + setFlag(SELECTABLE, selectable); + return this; + } + + public boolean isSelectable() { + return isFlagSet(SELECTABLE); + } + + public EditorFlags setEditable(boolean canEdit) { + setFlag(EDITABLE, canEdit); + return this; + } + + public boolean isEditable() { + return isFlagSet(EDITABLE); + } + + public EditorFlags setVisible(boolean visible) { + setFlag(VISIBLE, visible); + return this; + } + + public boolean isVisible() { + return isFlagSet(VISIBLE); + } + + public EditorFlags setChildrenVisible(boolean childrenVisible) { + setFlag(CHILDREN_VISIBLE, childrenVisible); + return this; + } + + public boolean isChildrenVisible() { + return isFlagSet(CHILDREN_VISIBLE); + } + + private void setFlag(int flag, boolean set) { + if (set) { + this.flags |= flag; + } else { + this.flags &= ~flag; + } + } + + private boolean isFlagSet(int flag) { + return (flags & flag) != 0; + } + + int asInt() { + return persistedFlags; + } + + int getCurrentState() { + return flags; + } + + public void persist() { + persistedFlags = flags; + } + + public void reset() { + restoreState(persistedFlags); + } + + void restoreState(int flags) { + this.flags = flags; + } + + void mark() { + markedFlags = flags; + } + + void restore() { + flags = markedFlags; + } + + public void set(@NonNull EditorFlags from) { + this.persistedFlags = from.persistedFlags; + this.flags = from.flags; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java new file mode 100644 index 0000000000000000000000000000000000000000..f9dc113d7c81b79b43799b3cc9651bb40af95728 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -0,0 +1,818 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; +import org.thoughtcrime.securesms.imageeditor.UndoRedoStackListener; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a + * reference to the {@link EditorElementHierarchy}. + *

+ * As such it is the entry point for all operations that change the image. + */ +public final class EditorModel implements Parcelable, RendererContext.Ready { + + public static final int Z_MASK = -1; + public static final int Z_DRAWING = 0; + public static final int Z_STICKERS = 0; + public static final int Z_TEXT = 1; + + private static final Runnable NULL_RUNNABLE = () -> { + }; + + private static final int MINIMUM_OUTPUT_WIDTH = 1024; + + private static final int MINIMUM_CROP_PIXEL_COUNT = 100; + private static final Point MINIMUM_RATIO = new Point(15, 1); + + @NonNull + private Runnable invalidate = NULL_RUNNABLE; + + private UndoRedoStackListener undoRedoStackListener; + + private final UndoRedoStacks undoRedoStacks; + private final UndoRedoStacks cropUndoRedoStacks; + private final InBoundsMemory inBoundsMemory = new InBoundsMemory(); + + private EditorElementHierarchy editorElementHierarchy; + + private final RectF visibleViewPort = new RectF(); + private final Point size; + private final boolean circleEditing; + + public EditorModel() { + this(false, EditorElementHierarchy.create()); + } + + private EditorModel(@NonNull Parcel in) { + ClassLoader classLoader = getClass().getClassLoader(); + this.circleEditing = in.readByte() == 1; + this.size = new Point(in.readInt(), in.readInt()); + //noinspection ConstantConditions + this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); + this.undoRedoStacks = in.readParcelable(classLoader); + this.cropUndoRedoStacks = in.readParcelable(classLoader); + } + + public EditorModel(boolean circleEditing, @NonNull EditorElementHierarchy editorElementHierarchy) { + this.circleEditing = circleEditing; + this.size = new Point(1024, 1024); + this.editorElementHierarchy = editorElementHierarchy; + this.undoRedoStacks = new UndoRedoStacks(50); + this.cropUndoRedoStacks = new UndoRedoStacks(50); + } + + public static EditorModel create() { + return new EditorModel(false, EditorElementHierarchy.create()); + } + + public static EditorModel createForCircleEditing() { + EditorModel editorModel = new EditorModel(true, EditorElementHierarchy.createForCircleEditing()); + editorModel.setCropAspectLock(true); + return editorModel; + } + + public void setInvalidate(@Nullable Runnable invalidate) { + this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE; + } + + public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + + updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); + } + + /** + * Renders tree with the following matrix: + *

+ * viewModelMatrix * matrix * editorMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix + * + * @param rendererContext Canvas to draw on to. + * @param renderOnTop This element will appear on top of the overlay. + */ + public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) { + EditorElement root = editorElementHierarchy.getRoot(); + if (renderOnTop != null) { + root.forAllInTree(element -> element.getFlags().mark()); + + renderOnTop.getFlags().setVisible(false); + } + + // pass 1 + root.draw(rendererContext); + + if (renderOnTop != null) { + // hide all + try { + root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element)); + + // pass 2 + root.draw(rendererContext); + } finally { + root.forAllInTree(element -> element.getFlags().restore()); + } + } + } + + public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = new Matrix(); + if (findElement(element, viewMatrix, inverse)) { + return inverse; + } + return null; + } + + private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = findElementInverseMatrix(element, viewMatrix); + if (inverse != null) { + Matrix regular = new Matrix(); + inverse.invert(regular); + return regular; + } + return null; + } + + public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix); + } + + private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element; + } + + public void pushUndoPoint() { + boolean cropping = isCropping(); + if (cropping && !currentCropIsAcceptable()) { + return; + } + + getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot()); + } + + public void undo() { + boolean cropping = isCropping(); + UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping); + + undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping); + + updateUndoRedoAvailableState(stacks); + } + + public void redo() { + boolean cropping = isCropping(); + UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping); + + undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping); + + updateUndoRedoAvailableState(stacks); + } + + private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) { + final EditorElement oldRootElement = editorElementHierarchy.getRoot(); + final EditorElement popped = fromStack.pop(oldRootElement); + + if (popped != null) { + editorElementHierarchy = EditorElementHierarchy.create(popped); + toStack.tryPush(oldRootElement); + + restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState); + invalidate.run(); + + // re-zoom image root as the view port might be different now + editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + + inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + } + } + + private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) { + Map fromMap = getElementMap(fromRootElement); + Map toMap = getElementMap(toRootElement); + + for (EditorElement fromElement : fromMap.values()) { + fromElement.stopAnimation(); + EditorElement toElement = toMap.get(fromElement.getId()); + if (toElement != null) { + toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate); + + if (keepEditorState) { + toElement.getEditorMatrix().set(fromElement.getEditorMatrix()); + toElement.getFlags().set(fromElement.getFlags()); + } + } else { + // element is removed + EditorElement parentFrom = fromRootElement.parentOf(fromElement); + if (parentFrom != null) { + EditorElement toParent = toMap.get(parentFrom.getId()); + if (toParent != null) { + toParent.addDeletedChildFadingOut(fromElement, onInvalidate); + } + } + } + } + + for (EditorElement toElement : toMap.values()) { + if (!fromMap.containsKey(toElement.getId())) { + // new item + toElement.animateFadeIn(onInvalidate); + } + } + } + + private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) { + if (undoRedoStackListener == null) return; + + EditorElement root = editorElementHierarchy.getRoot(); + + undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root)); + } + + private static Map getElementMap(@NonNull EditorElement element) { + final Map result = new HashMap<>(); + element.buildMap(result); + return result; + } + + public void startCrop() { + pushUndoPoint(); + cropUndoRedoStacks.clear(editorElementHierarchy.getRoot()); + editorElementHierarchy.startCrop(invalidate); + inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + updateUndoRedoAvailableState(cropUndoRedoStacks); + } + + public void doneCrop() { + editorElementHierarchy.doneCrop(visibleViewPort, invalidate); + updateUndoRedoAvailableState(undoRedoStacks); + } + + public void setCropAspectLock(boolean locked) { + EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags(); + int currentState = flags.setAspectLocked(locked).getCurrentState(); + + flags.reset(); + flags.setAspectLocked(locked) + .persist(); + flags.restoreState(currentState); + } + + public boolean isCropAspectLocked() { + return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked(); + } + + public void postEdit(boolean allowScaleToRepairCrop) { + boolean cropping = isCropping(); + if (cropping) { + ensureFitsBounds(allowScaleToRepairCrop); + } + + updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping)); + } + + /** + * @param cropping Set to true if cropping is underway. + * @return The correct stack for the mode of operation. + */ + private UndoRedoStacks getActiveUndoRedoStacks(boolean cropping) { + return cropping ? cropUndoRedoStacks : undoRedoStacks; + } + + private void ensureFitsBounds(boolean allowScaleToRepairCrop) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + if (mainImage == null) return; + + EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement(); + + if (!currentCropIsAcceptable()) { + if (allowScaleToRepairCrop) { + if (!tryToScaleToFit(cropEditorElement, 0.9f)) { + tryToScaleToFit(mainImage, 2f); + } + } else { + tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix()); + } + + if (!currentCropIsAcceptable()) { + inBoundsMemory.restore(mainImage, cropEditorElement, invalidate); + } else { + inBoundsMemory.push(mainImage, cropEditorElement); + } + } + + editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate); + } + + /** + * Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true. + *

+ * Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards. + * + * @param element The element to be scaled. If successful, it will be animated to the correct position. + * @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image. + * @return true if successfully scaled the element. false if the element was left unchanged. + */ + private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) { + return Bisect.bisectToTest(element, + 1, + scaleAtMost, + this::cropIsWithinMainImageBounds, + (matrix, scale) -> matrix.preScale(scale, scale), + invalidate); + } + + /** + * Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true. + * If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y. + * + * @param element The element to be translated. If successful, it will be animated to the correct position. + * @param translateXAtMost The maximum translation to apply in the x axis. + * @param translateYAtMost The maximum translation to apply in the y axis. + * @return a matrix if successfully translated the element. null if the element unable to be translated to fit. + */ + private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) { + return Bisect.bisectToTest(element, + 0, + 1, + this::cropIsWithinMainImageBounds, + (matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost)); + } + + /** + * Tries to fix an element that is out of bounds by adjusting it's translation. + * + * @param element Element to move. + * @param lastKnownGoodPosition Last known good position of element. + * @return true iff fixed the element. + */ + private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) { + final Matrix elementMatrix = element.getLocalMatrix(); + final Matrix original = new Matrix(elementMatrix); + final float[] current = new float[9]; + final float[] lastGood = new float[9]; + Matrix matrix; + + elementMatrix.getValues(current); + lastKnownGoodPosition.getValues(lastGood); + + final float xTranslate = current[2] - lastGood[2]; + final float yTranslate = current[5] - lastGood[5]; + + if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) { + return false; + } + + float pass1X; + float pass1Y; + + float pass2X; + float pass2Y; + + // try the fix by the smallest user translation first + if (Math.abs(xTranslate) < Math.abs(yTranslate)) { + // try to bisect along x + pass1X = -xTranslate; + pass1Y = 0; + + // then y + pass2X = 0; + pass2Y = -yTranslate; + } else { + // try to bisect along y + pass1X = 0; + pass1Y = -yTranslate; + + // then x + pass2X = -xTranslate; + pass2Y = 0; + } + + matrix = tryToTranslateToFit(element, pass1X, pass1Y); + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + matrix = tryToTranslateToFit(element, pass2X, pass2Y); + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + // apply pass 1 fully + elementMatrix.postTranslate(pass1X, pass1Y); + + matrix = tryToTranslateToFit(element, pass2X, pass2Y); + elementMatrix.set(original); + + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + return false; + } + + public void dragDropRelease() { + editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate); + } + + /** + * Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that) + * and all points must be within the bounds. + */ + private boolean currentCropIsAcceptable() { + Point outputSize = getOutputSize(); + int outputPixelCount = outputSize.x * outputSize.y; + int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT); + + Point thinnestRatio = MINIMUM_RATIO; + + if (compareRatios(size, thinnestRatio) < 0) { + // original is narrower than the thinnestRatio + thinnestRatio = size; + } + + return compareRatios(outputSize, thinnestRatio) >= 0 && + outputPixelCount >= minimumPixelCount && + cropIsWithinMainImageBounds(); + } + + /** + * -1 iff a is a narrower ratio than b. + * +1 iff a is a squarer ratio than b. + * 0 if the ratios are the same. + */ + private static int compareRatios(@NonNull Point a, @NonNull Point b) { + int smallA = Math.min(a.x, a.y); + int largeA = Math.max(a.x, a.y); + + int smallB = Math.min(b.x, b.y); + int largeB = Math.max(b.x, b.y); + + return Integer.compare(smallA * largeB, smallB * largeA); + } + + /** + * @return true if and only if the current crop rect is fully in the bounds. + */ + private boolean cropIsWithinMainImageBounds() { + return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop()); + } + + /** + * Called as edits are underway. + */ + public void moving(@NonNull EditorElement editorElement) { + if (!isCropping()) return; + + EditorElement mainImage = editorElementHierarchy.getMainImage(); + EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement(); + + if (editorElement == mainImage || editorElement == cropEditorElement) { + if (currentCropIsAcceptable()) { + inBoundsMemory.push(mainImage, cropEditorElement); + } + } + } + + public void setVisibleViewPort(@NonNull RectF visibleViewPort) { + this.visibleViewPort.set(visibleViewPort); + this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + } + + public Set getUniqueColorsIgnoringAlpha() { + final Set colors = new LinkedHashSet<>(); + + editorElementHierarchy.getRoot().forAllInTree(element -> { + Renderer renderer = element.getRenderer(); + if (renderer instanceof ColorableRenderer) { + colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000); + } + }); + + return colors; + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorModel createFromParcel(Parcel in) { + return new EditorModel(in); + } + + @Override + public EditorModel[] newArray(int size) { + return new EditorModel[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (circleEditing ? 1 : 0)); + dest.writeInt(size.x); + dest.writeInt(size.y); + dest.writeParcelable(editorElementHierarchy.getRoot(), flags); + dest.writeParcelable(undoRedoStacks, flags); + dest.writeParcelable(cropUndoRedoStacks, flags); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public @NonNull Bitmap render(@NonNull Context context) { + return render(context, null); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) { + EditorElement image = editorElementHierarchy.getFlipRotate(); + RectF cropRect = editorElementHierarchy.getCropRect(); + Point outputSize = size != null ? size : getOutputSize(); + + Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888); + try { + Canvas canvas = new Canvas(bitmap); + RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL); + + RectF bitmapArea = new RectF(); + bitmapArea.right = bitmap.getWidth(); + bitmapArea.bottom = bitmap.getHeight(); + + Matrix viewMatrix = new Matrix(); + viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL); + + rendererContext.setIsEditing(false); + rendererContext.setBlockingLoad(true); + + EditorElement overlay = editorElementHierarchy.getOverlay(); + overlay.getFlags().setVisible(false).setChildrenVisible(false); + + try { + rendererContext.canvasMatrix.initial(viewMatrix); + image.draw(rendererContext); + } finally { + overlay.getFlags().reset(); + } + } catch (Exception e) { + bitmap.recycle(); + throw e; + } + return bitmap; + } + + @NonNull + private Point getOutputSize() { + PointF outputSize = editorElementHierarchy.getOutputSize(size); + + int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x); + int height = (int) (width * outputSize.y / outputSize.x); + + return new Point(width, height); + } + + @NonNull + public Point getOutputSizeMaxWidth(int maxDimension) { + PointF outputSize = editorElementHierarchy.getOutputSize(size); + + int width = Math.min(maxDimension, (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x)); + int height = (int) (width * outputSize.y / outputSize.x); + + if (height > maxDimension) { + height = maxDimension; + width = (int) (height * outputSize.x / outputSize.y); + } + + return new Point(width, height); + } + + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) { + boolean changedBefore = isChanged(); + Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix(); + this.size.set(size.x, size.y); + if (imageCropMatrix.isIdentity()) { + imageCropMatrix.set(cropMatrix); + + if (circleEditing) { + Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); + if (size.x > size.y) { + userCropMatrix.setScale(size.y / (float) size.x, 1f); + } else { + userCropMatrix.setScale(1f, size.x / (float) size.y); + } + } + + editorElementHierarchy.doneCrop(visibleViewPort, null); + + if (!changedBefore) { + undoRedoStacks.clear(editorElementHierarchy.getRoot()); + } + + if (circleEditing) { + startCrop(); + } + } + } + } + + private boolean isRendererOfMainImage(@NonNull Renderer renderer) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null; + return mainImageRenderer == renderer; + } + + /** + * Add a new {@link EditorElement} centered in the current visible crop area. + * + * @param element New element to add. + * @param scale Initial scale for new element. + */ + public void addElementCentered(@NonNull EditorElement element, float scale) { + Matrix localMatrix = element.getLocalMatrix(); + + editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix); + + localMatrix.preScale(scale, scale); + addElement(element); + } + + /** + * Add an element to the main image, or if there is no main image, make the new element the main image. + * + * @param element New element to add. + */ + public void addElement(@NonNull EditorElement element) { + pushUndoPoint(); + addElementWithoutPushUndo(element); + } + + public void addElementWithoutPushUndo(@NonNull EditorElement element) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot(); + + parent.addElement(element); + + if (parent != mainImage) { + undoRedoStacks.clear(editorElementHierarchy.getRoot()); + } + + updateUndoRedoAvailableState(undoRedoStacks); + } + + public boolean isChanged() { + return undoRedoStacks.isChanged(editorElementHierarchy.getRoot()); + } + + public RectF findCropRelativeToRoot() { + return findCropRelativeTo(editorElementHierarchy.getRoot()); + } + + RectF findCropRelativeTo(EditorElement element) { + return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element); + } + + RectF findRelativeBounds(EditorElement from, EditorElement to) { + Matrix relative = findRelativeMatrix(from, to); + + RectF dst = new RectF(Bounds.FULL_BOUNDS); + if (relative != null) { + relative.mapRect(dst, Bounds.FULL_BOUNDS); + } + return dst; + } + + /** + * Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element. + * + * @param from + * @param to + * @return + */ + @Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) { + Matrix matrix = findElementInverseMatrix(to, new Matrix()); + Matrix outOf = findElementMatrix(from, new Matrix()); + + if (outOf != null && matrix != null) { + matrix.preConcat(outOf); + return matrix; + } + return null; + } + + public void rotate90clockwise() { + flipRotate(90, 1, 1); + } + + public void rotate90anticlockwise() { + flipRotate(-90, 1, 1); + } + + public void flipHorizontal() { + flipRotate(0, -1, 1); + } + + public void flipVertical() { + flipRotate(0, 1, -1); + } + + private void flipRotate(int degrees, int scaleX, int scaleY) { + pushUndoPoint(); + editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate); + updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); + } + + public EditorElement getRoot() { + return editorElementHierarchy.getRoot(); + } + + public EditorElement getMainImage() { + return editorElementHierarchy.getMainImage(); + } + + public void delete(@NonNull EditorElement editorElement) { + editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate)); + } + + public @Nullable EditorElement findById(@NonNull UUID uuid) { + return getElementMap(getRoot()).get(uuid); + } + + /** + * Changes the temporary view so that the text element is centered in it. + * + * @param entity Entity to center on. + * @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix + * to leave space for the keyboard for example. + */ + public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) { + Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix()); + if (elementInverseMatrix != null) { + EditorElement root = editorElementHierarchy.getRoot(); + + elementInverseMatrix.preConcat(root.getEditorMatrix()); + + textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix); + + root.animateEditorTo(elementInverseMatrix, invalidate); + } + } + + public void zoomOut() { + editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate); + } + + public void indicateSelected(@NonNull EditorElement selected) { + selected.singleScalePulse(invalidate); + } + + public boolean isCropping() { + return editorElementHierarchy.getCropEditorElement().getFlags().isVisible(); + } + + /** + * Returns a matrix that maps bounds to the crop area. + */ + public Matrix getInverseCropPosition() { + Matrix matrix = new Matrix(); + matrix.set(findRelativeMatrix(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement())); + matrix.postConcat(editorElementHierarchy.getFlipRotate().getLocalMatrix()); + + Matrix positionRelativeToCrop = new Matrix(); + matrix.invert(positionRelativeToCrop); + return positionRelativeToCrop; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java new file mode 100644 index 0000000000000000000000000000000000000000..468e288f0f01e1828e1d3fef0fa1eb0fb6babe7c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.Stack; + +/** + * Contains a stack of elements for undo and redo stacks. + *

+ * Elements are mutable, so this stack serializes the element and keeps a stack of serialized data. + *

+ * The stack has a {@link #limit} and if it exceeds that limit during a push the second to earliest item + * is removed so that it can always go back to the first state. Effectively collapsing the history for + * the start of the stack. + */ +final class ElementStack implements Parcelable { + + private final int limit; + private final Stack stack = new Stack<>(); + + ElementStack(int limit) { + this.limit = limit; + } + + private ElementStack(@NonNull Parcel in) { + this(in.readInt()); + final int count = in.readInt(); + for (int i = 0; i < count; i++) { + stack.add(i, in.createByteArray()); + } + } + + /** + * Pushes an element to the stack iff the element's serialized value is different to any found at + * the top of the stack. + *

+ * Removes the second to earliest item if it is overflowing. + * + * @param element new editor element state. + * @return true iff the pushed item was different to the top item. + */ + boolean tryPush(@NonNull EditorElement element) { + byte[] bytes = getBytes(element); + boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek()); + + if (push) { + stack.push(bytes); + if (stack.size() > limit) { + stack.remove(1); + } + } + return push; + } + + static byte[] getBytes(@NonNull Parcelable parcelable) { + Parcel parcel = Parcel.obtain(); + byte[] bytes; + try { + parcel.writeParcelable(parcelable, 0); + bytes = parcel.marshall(); + } finally { + parcel.recycle(); + } + return bytes; + } + + /** + * Pops the first different state from the supplied element. + */ + @Nullable EditorElement pop(@NonNull EditorElement element) { + if (stack.empty()) return null; + + byte[] elementBytes = getBytes(element); + byte[] stackData = null; + + while (!stack.empty() && stackData == null) { + byte[] topData = stack.pop(); + + if (!Arrays.equals(topData, elementBytes)) { + stackData = topData; + } + } + + if (stackData == null) return null; + + Parcel parcel = Parcel.obtain(); + try { + parcel.unmarshall(stackData, 0, stackData.length); + parcel.setDataPosition(0); + return parcel.readParcelable(EditorElement.class.getClassLoader()); + } finally { + parcel.recycle(); + } + } + + void clear() { + stack.clear(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ElementStack createFromParcel(Parcel in) { + return new ElementStack(in); + } + + @Override + public ElementStack[] newArray(int size) { + return new ElementStack[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(limit); + final int count = stack.size(); + dest.writeInt(count); + for (int i = 0; i < count; i++) { + dest.writeByteArray(stack.get(i)); + } + } + + boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) { + if (stack.isEmpty()) return false; + + byte[] currentStateBytes = getBytes(element); + + for (byte[] item : stack) { + if (!Arrays.equals(item, currentStateBytes)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java new file mode 100644 index 0000000000000000000000000000000000000000..e048261efdcdf6659e186b484a7fbe8bd50f83c2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class InBoundsMemory { + + private final Matrix lastGoodUserCrop = new Matrix(); + private final Matrix lastGoodMainImage = new Matrix(); + + void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) { + if (mainImage == null) { + lastGoodMainImage.reset(); + } else { + lastGoodMainImage.set(mainImage.getLocalMatrix()); + lastGoodMainImage.preConcat(mainImage.getEditorMatrix()); + } + + lastGoodUserCrop.set(userCrop.getLocalMatrix()); + lastGoodUserCrop.preConcat(userCrop.getEditorMatrix()); + } + + void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) { + if (mainImage != null) { + mainImage.animateLocalTo(lastGoodMainImage, invalidate); + } + cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate); + } + + Matrix getLastKnownGoodMainImageMatrix() { + return new Matrix(lastGoodMainImage); + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..aad9eed7efc6991b1877dbcb6e4ab90eba52b8ac --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.graphics.RectF; +import android.os.Parcel; +import androidx.annotation.NonNull; + +import java.util.UUID; + +public final class ParcelUtils { + + private ParcelUtils() { + } + + public static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + dest.writeFloatArray(values); + } + + public static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) { + float[] values = new float[9]; + in.readFloatArray(values); + matrix.setValues(values); + } + + public static @NonNull Matrix readMatrix(@NonNull Parcel in) { + Matrix matrix = new Matrix(); + readMatrix(matrix, in); + return matrix; + } + + public static void writeRect(@NonNull Parcel dest, @NonNull RectF rect) { + dest.writeFloat(rect.left); + dest.writeFloat(rect.top); + dest.writeFloat(rect.right); + dest.writeFloat(rect.bottom); + } + + public static @NonNull RectF readRectF(@NonNull Parcel in) { + float left = in.readFloat(); + float top = in.readFloat(); + float right = in.readFloat(); + float bottom = in.readFloat(); + return new RectF(left, top, right, bottom); + } + + static UUID readUUID(@NonNull Parcel in) { + return new UUID(in.readLong(), in.readLong()); + } + + static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) { + dest.writeLong(uuid.getMostSignificantBits()); + dest.writeLong(uuid.getLeastSignificantBits()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..1447664490fb1e5874df7344c48e62c636fc79a0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; + +import java.util.UUID; + +/** + * A special {@link Renderer} that controls another {@link EditorElement}. + *

+ * It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of. + *

+ * The presence of this interface on the selected element is used to launch a ThumbDragEditSession. + */ +public interface ThumbRenderer extends Renderer { + + enum ControlPoint { + + CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y), + CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y), + + TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP), + BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM), + + TOP_LEFT (Bounds.LEFT, Bounds.TOP), + TOP_RIGHT (Bounds.RIGHT, Bounds.TOP), + BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM), + BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM); + + private final float x; + private final float y; + + ControlPoint(float x, float y) { + this.x = x; + this.y = y; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public ControlPoint opposite() { + switch (this) { + case CENTER_LEFT: return CENTER_RIGHT; + case CENTER_RIGHT: return CENTER_LEFT; + case TOP_CENTER: return BOTTOM_CENTER; + case BOTTOM_CENTER: return TOP_CENTER; + case TOP_LEFT: return BOTTOM_RIGHT; + case TOP_RIGHT: return BOTTOM_LEFT; + case BOTTOM_LEFT: return TOP_RIGHT; + case BOTTOM_RIGHT: return TOP_LEFT; + default: + throw new RuntimeException(); + } + } + + public boolean isHorizontalCenter() { + return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT; + } + + public boolean isVerticalCenter() { + return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER; + } + + public boolean isCenter() { + return isHorizontalCenter() || isVerticalCenter(); + } + } + + ControlPoint getControlPoint(); + + UUID getElementToControl(); +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java new file mode 100644 index 0000000000000000000000000000000000000000..8f680bdd0d2ef09de18fbd76e079ac06bc20f8ab --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +final class UndoRedoStacks implements Parcelable { + + private final ElementStack undoStack; + private final ElementStack redoStack; + + @NonNull + private byte[] unchangedState; + + UndoRedoStacks(int limit) { + this(new ElementStack(limit), new ElementStack(limit), null); + } + + private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) { + this.undoStack = undoStack; + this.redoStack = redoStack; + this.unchangedState = unchangedState != null ? unchangedState : new byte[0]; + } + + public static final Creator CREATOR = new Creator() { + @Override + public UndoRedoStacks createFromParcel(Parcel in) { + return new UndoRedoStacks( + in.readParcelable(ElementStack.class.getClassLoader()), + in.readParcelable(ElementStack.class.getClassLoader()), + in.createByteArray() + ); + } + + @Override + public UndoRedoStacks[] newArray(int size) { + return new UndoRedoStacks[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(undoStack, flags); + dest.writeParcelable(redoStack, flags); + dest.writeByteArray(unchangedState); + } + + @Override + public int describeContents() { + return 0; + } + + ElementStack getUndoStack() { + return undoStack; + } + + ElementStack getRedoStack() { + return redoStack; + } + + void pushState(@NonNull EditorElement element) { + if (undoStack.tryPush(element)) { + redoStack.clear(); + } + } + + void clear(@NonNull EditorElement element) { + undoStack.clear(); + redoStack.clear(); + unchangedState = ElementStack.getBytes(element); + } + + boolean isChanged(@NonNull EditorElement element) { + return !Arrays.equals(ElementStack.getBytes(element), unchangedState); + } + + /** + * As long as there is something different in the stack somewhere, then we can undo. + */ + boolean canUndo(@NonNull EditorElement currentState) { + return undoStack.stackContainsStateDifferentFrom(currentState); + } + + /** + * As long as there is something different in the stack somewhere, then we can redo. + */ + boolean canRedo(@NonNull EditorElement currentState) { + return redoStack.stackContainsStateDifferentFrom(currentState); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java new file mode 100644 index 0000000000000000000000000000000000000000..dedbbc3efebb4dbd9cc77f370037cf7e94f4eac3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +/** + * Given points for a line to go though, automatically finds control points. + *

+ * Based on http://www.particleincell.com/2012/bezier-splines/ + *

+ * Can then draw that line to a {@link Canvas} given a {@link Paint}. + *

+ * Allocation efficient so that adding new points does not result in lots of array allocations. + */ +final class AutomaticControlPointBezierLine implements Parcelable { + + private static final int INITIAL_CAPACITY = 256; + + private float[] x; + private float[] y; + + // control points + private float[] p1x; + private float[] p1y; + private float[] p2x; + private float[] p2y; + + private int count; + + private final Path path = new Path(); + + private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) { + this.count = count; + this.x = x != null ? x : new float[INITIAL_CAPACITY]; + this.y = y != null ? y : new float[INITIAL_CAPACITY]; + allocControlPointsAndWorkingMemory(this.x.length); + recalculateControlPoints(); + } + + AutomaticControlPointBezierLine() { + this(null, null, 0); + } + + void reset() { + count = 0; + path.reset(); + } + + /** + * Adds a new point to the end of the line but ignores points that are too close to the last. + * + * @param x new x point + * @param y new y point + * @param thickness the maximum distance to allow, line thickness is recommended. + */ + void addPointFiltered(float x, float y, float thickness) { + if (count > 0) { + float dx = this.x[count - 1] - x; + float dy = this.y[count - 1] - y; + if (dx * dx + dy * dy < thickness * thickness) { + return; + } + } + addPoint(x, y); + } + + /** + * Adds a new point to the end of the line. + * + * @param x new x point + * @param y new y point + */ + void addPoint(float x, float y) { + if (this.x == null || count == this.x.length) { + resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY); + } + + this.x[count] = x; + this.y[count] = y; + count++; + + recalculateControlPoints(); + } + + private void resize(int newCapacity) { + x = Arrays.copyOf(x, newCapacity); + y = Arrays.copyOf(y, newCapacity); + allocControlPointsAndWorkingMemory(newCapacity - 1); + } + + private void allocControlPointsAndWorkingMemory(int max) { + p1x = new float[max]; + p1y = new float[max]; + p2x = new float[max]; + p2y = new float[max]; + + a = new float[max]; + b = new float[max]; + c = new float[max]; + r = new float[max]; + } + + private void recalculateControlPoints() { + path.reset(); + + if (count > 2) { + computeControlPoints(x, p1x, p2x, count); + computeControlPoints(y, p1y, p2y, count); + } + + path.moveTo(x[0], y[0]); + switch (count) { + case 1: + path.lineTo(x[0], y[0]); + break; + case 2: + path.lineTo(x[1], y[1]); + break; + default: + for (int i = 1; i < count - 1; i++) { + path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]); + } + } + } + + /** + * Draw the line. + * + * @param canvas The canvas to draw on. + * @param paint The paint to use. + */ + void draw(@NonNull Canvas canvas, @NonNull Paint paint) { + canvas.drawPath(path, paint); + } + + // rhs vector for computeControlPoints method + private float[] a; + private float[] b; + private float[] c; + private float[] r; + + /** + * Based on http://www.particleincell.com/2012/bezier-splines/ + * + * @param k knots x or y, must be at least 2 entries + * @param p1 corresponding first control point x or y + * @param p2 corresponding second control point x or y + * @param count number of k to process + */ + private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) { + final int n = count - 1; + + // left most segment + a[0] = 0; + b[0] = 2; + c[0] = 1; + r[0] = k[0] + 2 * k[1]; + + // internal segments + for (int i = 1; i < n - 1; i++) { + a[i] = 1; + b[i] = 4; + c[i] = 1; + r[i] = 4 * k[i] + 2 * k[i + 1]; + } + + // right segment + a[n - 1] = 2; + b[n - 1] = 7; + c[n - 1] = 0; + r[n - 1] = 8 * k[n - 1] + k[n]; + + // solves Ax=b with the Thomas algorithm + for (int i = 1; i < n; i++) { + float m = a[i] / b[i - 1]; + b[i] = b[i] - m * c[i - 1]; + r[i] = r[i] - m * r[i - 1]; + } + + p1[n - 1] = r[n - 1] / b[n - 1]; + for (int i = n - 2; i >= 0; --i) { + p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i]; + } + + // we have p1, now compute p2 + for (int i = 0; i < n - 1; i++) { + p2[i] = 2 * k[i + 1] - p1[i + 1]; + } + + p2[n - 1] = 0.5f * (k[n] + p1[n - 1]); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AutomaticControlPointBezierLine createFromParcel(Parcel in) { + float[] x = in.createFloatArray(); + float[] y = in.createFloatArray(); + return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0); + } + + @Override + public AutomaticControlPointBezierLine[] newArray(int size) { + return new AutomaticControlPointBezierLine[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeFloatArray(Arrays.copyOfRange(x, 0, count)); + dest.writeFloatArray(Arrays.copyOfRange(y, 0, count)); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..30423b9c4037318c4fe10e38e2aa68a01411a534 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type. + */ +public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer { + + private final Paint paint; + private final AutomaticControlPointBezierLine bezierLine; + private final Paint.Cap cap; + + @Nullable + private final RectF clipRect; + + private int color; + private float thickness; + + private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) { + this.paint = new Paint(); + this.color = color; + this.thickness = thickness; + this.cap = cap; + this.clipRect = clipRect; + this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine(); + + updatePaint(); + } + + public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) { + this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null); + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(int color) { + if (this.color != color) { + this.color = color; + updatePaint(); + invalidate(); + } + } + + public void setThickness(float thickness) { + if (this.thickness != thickness) { + this.thickness = thickness; + updatePaint(); + invalidate(); + } + } + + private void updatePaint() { + paint.setColor(color); + paint.setStrokeWidth(thickness); + paint.setStyle(Paint.Style.STROKE); + paint.setAntiAlias(true); + paint.setStrokeCap(cap); + } + + public void setFirstPoint(PointF point) { + bezierLine.reset(); + bezierLine.addPoint(point.x, point.y); + invalidate(); + } + + public void addNewPoint(PointF point) { + if (cap != Paint.Cap.ROUND) { + bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f); + } else { + bezierLine.addPoint(point.x, point.y); + } + invalidate(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + Canvas canvas = rendererContext.canvas; + canvas.save(); + if (clipRect != null) { + canvas.clipRect(clipRect); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + paint.setXfermode(rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint().getXfermode() : null); + + bezierLine.draw(canvas, paint); + + paint.setAlpha(alpha); + rendererContext.canvas.restore(); + } + + @Override + public boolean hitTest(float x, float y) { + return false; + } + + public static final Creator CREATOR = new Creator() { + @Override + public BezierDrawingRenderer createFromParcel(Parcel in) { + int color = in.readInt(); + float thickness = in.readFloat(); + Paint.Cap cap = Paint.Cap.values()[in.readInt()]; + AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader()); + RectF clipRect = in.readParcelable(RectF.class.getClassLoader()); + + return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect); + } + + @Override + public BezierDrawingRenderer[] newArray(int size) { + return new BezierDrawingRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeFloat(thickness); + dest.writeInt(cap.ordinal()); + dest.writeParcelable(bezierLine, flags); + dest.writeParcelable(clipRect, flags); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..a397e06e46ebaf059b72606dae7610e349f2f3a5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color} + * and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color}, + * {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}. + *

+ * Hit tests outside of the bounds. + */ +public final class CropAreaRenderer implements Renderer { + + @ColorRes + private final int color; + private final boolean renderCenterThumbs; + + private final Path cropClipPath = new Path(); + private final Path screenClipPath = new Path(); + + private final RectF dst = new RectF(); + private final Paint paint = new Paint(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Resources resources = rendererContext.context.getResources(); + + canvas.clipPath(cropClipPath); + canvas.drawColor(ResourcesCompat.getColor(resources, color, null)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + + final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness); + final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10); + + paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null)); + + rendererContext.canvasMatrix.setToIdentity(); + screenClipPath.reset(); + screenClipPath.moveTo(dst.left, dst.top); + screenClipPath.lineTo(dst.right, dst.top); + screenClipPath.lineTo(dst.right, dst.bottom); + screenClipPath.lineTo(dst.left, dst.bottom); + screenClipPath.close(); + canvas.clipPath(screenClipPath); + canvas.translate(dst.left, dst.top); + + float halfDx = (dst.right - dst.left - size + thickness) / 2; + float halfDy = (dst.bottom - dst.top - size + thickness) / 2; + + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(-halfDx, 0); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + rendererContext.restore(); + } + + public CropAreaRenderer(@ColorRes int color, boolean renderCenterThumbs) { + this.color = color; + this.renderCenterThumbs = renderCenterThumbs; + + cropClipPath.toggleInverseFillType(); + cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM); + cropClipPath.close(); + screenClipPath.toggleInverseFillType(); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull CropAreaRenderer createFromParcel(@NonNull Parcel in) { + return new CropAreaRenderer(in.readInt(), + in.readByte() == 1); + } + + @Override + public @NonNull CropAreaRenderer[] newArray(int size) { + return new CropAreaRenderer[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeByte((byte) (renderCenterThumbs ? 1 : 0)); + } + + @Override + public int describeContents() { + return 0; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..fd255e4d2cc3c9ca42e6c4f38173eac08135c115 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.lang.ref.WeakReference; + +/** + * Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk. + */ +abstract class InvalidateableRenderer implements Renderer { + + private WeakReference invalidate = new WeakReference<>(null); + + @Override + public void render(@NonNull RendererContext rendererContext) { + setInvalidate(rendererContext.invalidate); + } + + private void setInvalidate(RendererContext.Invalidate invalidate) { + if (invalidate != this.invalidate.get()) { + this.invalidate = new WeakReference<>(invalidate); + } + } + + protected void invalidate() { + RendererContext.Invalidate invalidate = this.invalidate.get(); + if (invalidate != null) { + invalidate.onInvalidate(this); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..6c662e9d18896a8bbf0242c97b03bb3b230d5293 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Path; +import android.os.Parcel; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders the {@link color} outside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class InverseFillRenderer implements Renderer { + + private final int color; + + private final Path path = new Path(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvas.save(); + rendererContext.canvas.clipPath(path); + rendererContext.canvas.drawColor(color); + rendererContext.canvas.restore(); + } + + public InverseFillRenderer(@ColorInt int color) { + this.color = color; + path.toggleInverseFillType(); + path.moveTo(Bounds.LEFT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + path.lineTo(Bounds.LEFT, Bounds.BOTTOM); + path.close(); + } + + private InverseFillRenderer(Parcel in) { + this(in.readInt()); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public InverseFillRenderer createFromParcel(Parcel in) { + return new InverseFillRenderer(in); + } + + @Override + public InverseFillRenderer[] newArray(int size) { + return new InverseFillRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..b5b7f350e6eae828cd7f518cd2a5c0106b439bef --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java @@ -0,0 +1,395 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import static java.util.Collections.emptyList; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.Parcel; +import android.view.animation.Interpolator; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.ArrayList; +import java.util.List; + +/** + * Renders multiple lines of {@link #text} in the specified {@link #color}. + *

+ * Scales down the text size of long lines to fit inside the {@link Bounds} width. + */ +public final class MultiLineTextRenderer extends InvalidateableRenderer implements ColorableRenderer { + + @NonNull + private String text = ""; + + @ColorInt + private int color; + + private final Paint paint = new Paint(); + private final Paint selectionPaint = new Paint(); + + private final float textScale; + + private int selStart; + private int selEnd; + private boolean hasFocus; + + private List lines = emptyList(); + + private ValueAnimator cursorAnimator; + private float cursorAnimatedValue; + + private final Matrix recommendedEditorMatrix = new Matrix(); + + public MultiLineTextRenderer(@Nullable String text, @ColorInt int color) { + setColor(color); + float regularTextSize = paint.getTextSize(); + paint.setAntiAlias(true); + paint.setTextSize(100); + paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + textScale = paint.getTextSize() / regularTextSize; + selectionPaint.setAntiAlias(true); + setText(text != null ? text : ""); + createLinesForText(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + + for (Line line : lines) { + line.render(rendererContext); + } + } + + @NonNull + public String getText() { + return text; + } + + public void setText(@NonNull String text) { + if (!this.text.equals(text)) { + this.text = text; + createLinesForText(); + } + } + + /** + * Post concats an additional matrix to the supplied matrix that scales and positions the editor + * so that all the text is visible. + * + * @param matrix editor matrix, already zoomed and positioned to fit the regular bounds. + */ + public void applyRecommendedEditorMatrix(@NonNull Matrix matrix) { + recommendedEditorMatrix.reset(); + + float scale = 1f; + for (Line line : lines) { + if (line.scale < scale) { + scale = line.scale; + } + } + + float yOff = 0; + for (Line line : lines) { + if (line.containsSelectionEnd()) { + break; + } else { + yOff -= line.heightInBounds; + } + } + + recommendedEditorMatrix.postTranslate(0, Bounds.TOP / 1.5f + yOff); + + recommendedEditorMatrix.postScale(scale, scale); + + matrix.postConcat(recommendedEditorMatrix); + } + + private void createLinesForText() { + String[] split = text.split("\n", -1); + + if (split.length == lines.size()) { + for (int i = 0; i < split.length; i++) { + lines.get(i).setText(split[i]); + } + } else { + lines = new ArrayList<>(split.length); + for (String s : split) { + lines.add(new Line(s)); + } + } + setSelection(selStart, selEnd); + } + + private class Line { + private final Matrix accentMatrix = new Matrix(); + private final Matrix decentMatrix = new Matrix(); + private final Matrix projectionMatrix = new Matrix(); + private final Matrix inverseProjectionMatrix = new Matrix(); + private final RectF selectionBounds = new RectF(); + private final RectF textBounds = new RectF(); + + private String text; + private int selStart; + private int selEnd; + private float ascentInBounds; + private float descentInBounds; + private float scale = 1f; + private float heightInBounds; + + Line(String text) { + this.text = text; + recalculate(); + } + + private void recalculate() { + RectF maxTextBounds = new RectF(); + Rect temp = new Rect(); + + getTextBoundsWithoutTrim(text, 0, text.length(), temp); + textBounds.set(temp); + + maxTextBounds.set(textBounds); + float widthLimit = 150 * textScale; + + scale = 1f / Math.max(1, maxTextBounds.right / widthLimit); + + maxTextBounds.right = widthLimit; + + if (showSelectionOrCursor()) { + Rect startTemp = new Rect(); + int startInString = Math.min(text.length(), Math.max(0, selStart)); + int endInString = Math.min(text.length(), Math.max(0, selEnd)); + String startText = this.text.substring(0, startInString); + + getTextBoundsWithoutTrim(startText, 0, startInString, startTemp); + + if (selStart != selEnd) { + // selection + getTextBoundsWithoutTrim(text, startInString, endInString, temp); + } else { + // cursor + paint.getTextBounds("|", 0, 1, temp); + int width = temp.width(); + + temp.left -= width; + temp.right -= width; + } + + temp.left += startTemp.right; + temp.right += startTemp.right; + selectionBounds.set(temp); + } + + projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + removeTranslate(projectionMatrix); + + float[] pts = { 0, paint.ascent(), 0, paint.descent() }; + projectionMatrix.mapPoints(pts); + ascentInBounds = pts[1]; + descentInBounds = pts[3]; + heightInBounds = descentInBounds - ascentInBounds; + + projectionMatrix.preTranslate(-textBounds.centerX(), 0); + projectionMatrix.invert(inverseProjectionMatrix); + + accentMatrix.setTranslate(0, -ascentInBounds); + decentMatrix.setTranslate(0, descentInBounds); + + invalidate(); + } + + private void removeTranslate(Matrix matrix) { + float[] values = new float[9]; + + matrix.getValues(values); + values[2] = 0; + values[5] = 0; + matrix.setValues(values); + } + + private boolean showSelectionOrCursor() { + return (selStart >= 0 || selEnd >= 0) && + (selStart <= text.length() || selEnd <= text.length()); + } + + private boolean containsSelectionEnd() { + return (selEnd >= 0) && + (selEnd <= text.length()); + } + + private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) { + Rect extra = new Rect(); + Rect xBounds = new Rect(); + + String cannotBeTrimmed = "x" + text.substring(Math.max(0, start), Math.min(text.length(), end)) + "x"; + + paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra); + paint.getTextBounds("x", 0, 1, xBounds); + result.set(extra); + result.right -= 2 * xBounds.width(); + + int temp = result.left; + result.left -= temp; + result.right -= temp; + } + + public boolean contains(float x, float y) { + float[] dst = new float[2]; + + inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y }); + + return textBounds.contains(dst[0], dst[1]); + } + + void setText(String text) { + if (!this.text.equals(text)) { + this.text = text; + recalculate(); + } + } + + public void render(@NonNull RendererContext rendererContext) { + // add our ascent for ourselves and the next lines + rendererContext.canvasMatrix.concat(accentMatrix); + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(projectionMatrix); + + if (hasFocus && showSelectionOrCursor()) { + if (selStart == selEnd) { + selectionPaint.setAlpha((int) (cursorAnimatedValue * 128)); + } else { + selectionPaint.setAlpha(128); + } + rendererContext.canvas.drawRect(selectionBounds, selectionPaint); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawText(text, 0, 0, paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + + // add our descent for the next lines + rendererContext.canvasMatrix.concat(decentMatrix); + } + + void setSelection(int selStart, int selEnd) { + if (selStart != this.selStart || selEnd != this.selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + recalculate(); + } + } + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(@ColorInt int color) { + if (this.color != color) { + this.color = color; + paint.setColor(color); + selectionPaint.setColor(color); + invalidate(); + } + } + + @Override + public boolean hitTest(float x, float y) { + for (Line line : lines) { + y += line.ascentInBounds; + if (line.contains(x, y)) return true; + y -= line.descentInBounds; + } + return false; + } + + public void setSelection(int selStart, int selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + for (Line line : lines) { + line.setSelection(selStart, selEnd); + + int length = line.text.length() + 1; // one for new line + + selStart -= length; + selEnd -= length; + } + } + + public void setFocused(boolean hasFocus) { + if (this.hasFocus != hasFocus) { + this.hasFocus = hasFocus; + if (cursorAnimator != null) { + cursorAnimator.cancel(); + cursorAnimator = null; + } + if (hasFocus) { + cursorAnimator = ValueAnimator.ofFloat(0, 1); + cursorAnimator.setInterpolator(pulseInterpolator()); + cursorAnimator.setRepeatCount(ValueAnimator.INFINITE); + cursorAnimator.setDuration(1000); + cursorAnimator.addUpdateListener(animation -> { + cursorAnimatedValue = (float) animation.getAnimatedValue(); + invalidate(); + }); + cursorAnimator.start(); + } else { + invalidate(); + } + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public MultiLineTextRenderer createFromParcel(Parcel in) { + return new MultiLineTextRenderer(in.readString(), in.readInt()); + } + + @Override + public MultiLineTextRenderer[] newArray(int size) { + return new MultiLineTextRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeInt(color); + } + + private static Interpolator pulseInterpolator() { + return input -> { + input *= 5; + if (input > 1) { + input = 4 - input; + } + return Math.max(0, Math.min(1, input)); + }; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..643e8388247f3c5ffc5464662eb660e1cbc8d044 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders an oval inside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class OvalGuideRenderer implements Renderer { + + private final @ColorRes int ovalGuideColor; + + private final Paint paint; + + private final RectF dst = new RectF(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Context context = rendererContext.context; + int stroke = context.getResources().getDimensionPixelSize(R.dimen.oval_guide_stroke_width); + float halfStroke = stroke / 2f; + + this.paint.setStrokeWidth(stroke); + paint.setColor(ContextCompat.getColor(context, ovalGuideColor)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + dst.set(dst.left + halfStroke, dst.top + halfStroke, dst.right - halfStroke, dst.bottom - halfStroke); + + rendererContext.canvasMatrix.setToIdentity(); + canvas.drawOval(dst, paint); + + rendererContext.restore(); + } + + public OvalGuideRenderer(@ColorRes int color) { + this.ovalGuideColor = color; + + this.paint = new Paint(); + this.paint.setStyle(Paint.Style.STROKE); + this.paint.setAntiAlias(true); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull OvalGuideRenderer createFromParcel(@NonNull Parcel in) { + return new OvalGuideRenderer(in.readInt()); + } + + @Override + public @NonNull OvalGuideRenderer[] newArray(int size) { + return new OvalGuideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(ovalGuideColor); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java b/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java new file mode 100644 index 0000000000000000000000000000000000000000..bfd145104c4688502b289bd9e0ee1324f7a3f610 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java @@ -0,0 +1,121 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobmanager; + +import android.os.PowerManager; + +import org.thoughtcrime.securesms.jobmanager.requirements.Requirement; + +import java.io.Serializable; + +/** + * An abstract class representing a unit of work that can be scheduled with + * the JobManager. This should be extended to implement tasks. + */ +public abstract class Job implements Serializable { + + private final JobParameters parameters; + + private transient int runIteration; + private transient PowerManager.WakeLock wakeLock; + + public Job(JobParameters parameters) { + this.parameters = parameters; + } + + public boolean isRequirementsMet() { + for (Requirement requirement : parameters.getRequirements()) { + if (!requirement.isPresent(this)) return false; + } + + return true; + } + + public String getGroupId() { + return parameters.getGroupId(); + } + + public int getRetryCount() { + return parameters.getRetryCount(); + } + + public long getRetryUntil() { + return parameters.getRetryUntil(); + } + + public void resetRunStats() { + runIteration = 0; + } + + public int getRunIteration() { + return runIteration; + } + + public boolean needsWakeLock() { + return parameters.needsWakeLock(); + } + + public long getWakeLockTimeout() { + return parameters.getWakeLockTimeout(); + } + + public void setWakeLock(PowerManager.WakeLock wakeLock) { + this.wakeLock = wakeLock; + } + + public PowerManager.WakeLock getWakeLock() { + return this.wakeLock; + } + + public void onRetry() { + runIteration++; + + for (Requirement requirement : parameters.getRequirements()) { + requirement.onRetry(this); + } + } + + /** + * Called after a job has been added to the JobManager queue. If it's a persistent job, + * the state has been persisted to disk before this method is called. + */ + public abstract void onAdded(); + + /** + * Called to actually execute the job. + * @throws Exception + */ + protected abstract void onRun() throws Exception; + + /** + * If onRun() throws an exception, this method will be called to determine whether the + * job should be retried. + * + * @param exception The exception onRun() threw. + * @return true if onRun() should be called again, false otherwise. + */ + public abstract boolean onShouldRetry(Exception exception); + + /** + * Called if a job fails to run (onShouldRetry returned false, or the number of retries exceeded + * the job's configured retry count. + */ + public abstract void onCanceled(); + + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/jobmanager/JobConsumer.java b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobConsumer.java new file mode 100644 index 0000000000000000000000000000000000000000..9ff187f20a71ee810378d2ed08121229eed995ce --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobConsumer.java @@ -0,0 +1,92 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; +import android.util.Log; + +class JobConsumer extends Thread { + + private static final String TAG = JobConsumer.class.getSimpleName(); + + enum JobResult { + SUCCESS, + FAILURE, + DEFERRED + } + + private final JobQueue jobQueue; + + public JobConsumer(String name, JobQueue jobQueue) { + super(name); + this.jobQueue = jobQueue; + } + + @Override + public void run() { + while (true) { + Job job = jobQueue.getNext(); + JobResult result = runJob(job); + + if (result == JobResult.DEFERRED) { + jobQueue.push(job); + } else { + if (result == JobResult.FAILURE) { + job.onCanceled(); + } + + if (job.getWakeLock() != null && job.getWakeLockTimeout() == 0) { + job.getWakeLock().release(); + } + + if (job.getGroupId() != null) { + jobQueue.setGroupIdAvailable(job.getGroupId()); + } + } + } + } + + private JobResult runJob(Job job) { + while (canRetry(job)) { + try { + job.onRun(); + return JobResult.SUCCESS; + } catch (Exception exception) { + Log.w(TAG, exception); + if (exception instanceof RuntimeException) { + throw (RuntimeException)exception; + } else if (!job.onShouldRetry(exception)) { + return JobResult.FAILURE; + } + + job.onRetry(); + if (!job.isRequirementsMet()) { + return JobResult.DEFERRED; + } + } + } + + return JobResult.FAILURE; + } + + private boolean canRetry(@NonNull Job job) { + if (job.getRetryCount() > 0) { + return job.getRunIteration() < job.getRetryCount(); + } + return System.currentTimeMillis() < job.getRetryUntil(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java new file mode 100644 index 0000000000000000000000000000000000000000..15e4683635ca79b1508a92efa0c74f489105a3b7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.content.Context; +import android.os.PowerManager; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * A JobManager allows you to enqueue {@link org.thoughtcrime.securesms.jobmanager.Job} tasks + * that are executed once a Job's {@link org.thoughtcrime.securesms.jobmanager.requirements.Requirement}s + * are met. + */ +public class JobManager { + + private final JobQueue jobQueue = new JobQueue(); + private final Executor eventExecutor = Executors.newSingleThreadExecutor(); + + private final Context context; + + public JobManager(Context context, int consumers) + { + this.context = context; + + for (int i=0;i. + */ +package org.thoughtcrime.securesms.jobmanager; + +import org.thoughtcrime.securesms.jobmanager.requirements.Requirement; + +import java.io.Serializable; +import java.util.LinkedList; +import java.util.List; + +/** + * The set of parameters that describe a {@link org.thoughtcrime.securesms.jobmanager.Job}. + */ +public class JobParameters implements Serializable { + + private static final long serialVersionUID = 4880456378402584584L; + + private final List requirements; + private final int retryCount; + private final long retryUntil; + private final String groupId; + private final boolean wakeLock; + private final long wakeLockTimeout; + + private JobParameters(List requirements, + String groupId, + int retryCount, long retryUntil, boolean wakeLock, + long wakeLockTimeout) + { + this.requirements = requirements; + this.groupId = groupId; + this.retryCount = retryCount; + this.retryUntil = retryUntil; + this.wakeLock = wakeLock; + this.wakeLockTimeout = wakeLockTimeout; + } + + public List getRequirements() { + return requirements; + } + + public int getRetryCount() { + return retryCount; + } + + public long getRetryUntil() { + return retryUntil; + } + + /** + * @return a builder used to construct JobParameters. + */ + public static Builder newBuilder() { + return new Builder(); + } + + public String getGroupId() { + return groupId; + } + + public boolean needsWakeLock() { + return wakeLock; + } + + public long getWakeLockTimeout() { + return wakeLockTimeout; + } + + public static class Builder { + private final List requirements = new LinkedList<>(); + private final int retryCount = 100; + private final long retryDuration = 0; + private String groupId = null; + private final boolean wakeLock = false; + private final long wakeLockTimeout = 0; + + /** + * Specify a groupId the job should belong to. Jobs with the same groupId are guaranteed to be + * executed serially. + * + * @param groupId The job's groupId. + * @return the builder. + */ + public Builder withGroupId(String groupId) { + this.groupId = groupId; + return this; + } + + /** + * @return the JobParameters instance that describes a Job. + */ + public JobParameters create() { + return new JobParameters(requirements, groupId, retryCount, System.currentTimeMillis() + retryDuration, wakeLock, wakeLockTimeout); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/jobmanager/JobQueue.java b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobQueue.java new file mode 100644 index 0000000000000000000000000000000000000000..1b01bd6e61c739ce8b81c2770d82e30d1ffffc2b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/jobmanager/JobQueue.java @@ -0,0 +1,103 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.ListIterator; +import java.util.Map; + +class JobQueue { + + private final Map activeGroupIds = new HashMap<>(); + private final LinkedList jobQueue = new LinkedList<>(); + + synchronized void add(Job job) { + jobQueue.add(job); + processJobAddition(job); + notifyAll(); + } + + private void processJobAddition(@NonNull Job job) { + if (isJobActive(job) && isGroupIdAvailable(job)) { + setGroupIdUnavailable(job); + } else if (!isGroupIdAvailable(job)) { + Job blockingJob = activeGroupIds.get(job.getGroupId()); + blockingJob.resetRunStats(); + } + } + + synchronized void push(Job job) { + jobQueue.addFirst(job); + } + + synchronized Job getNext() { + try { + Job nextAvailableJob; + + while ((nextAvailableJob = getNextAvailableJob()) == null) { + wait(); + } + + return nextAvailableJob; + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + synchronized void setGroupIdAvailable(String groupId) { + if (groupId != null) { + activeGroupIds.remove(groupId); + notifyAll(); + } + } + + private Job getNextAvailableJob() { + if (jobQueue.isEmpty()) return null; + + ListIterator iterator = jobQueue.listIterator(); + while (iterator.hasNext()) { + Job job = iterator.next(); + + if (job.isRequirementsMet() && isGroupIdAvailable(job)) { + iterator.remove(); + setGroupIdUnavailable(job); + return job; + } + } + + return null; + } + + private boolean isJobActive(@NonNull Job job) { + return job.getRetryUntil() > 0 && job.getRunIteration() > 0; + } + + private boolean isGroupIdAvailable(@NonNull Job requester) { + String groupId = requester.getGroupId(); + return groupId == null || !activeGroupIds.containsKey(groupId) || activeGroupIds.get(groupId).equals(requester); + } + + private void setGroupIdUnavailable(@NonNull Job job) { + String groupId = job.getGroupId(); + if (groupId != null) { + activeGroupIds.put(groupId, job); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/jobmanager/requirements/Requirement.java b/src/main/java/org/thoughtcrime/securesms/jobmanager/requirements/Requirement.java new file mode 100644 index 0000000000000000000000000000000000000000..7a09260b6e7ffd11ea4d4eb1852b891204bc4fd5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/jobmanager/requirements/Requirement.java @@ -0,0 +1,35 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobmanager.requirements; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Job; + +import java.io.Serializable; + +/** + * A Requirement that must be satisfied before a Job can run. + */ +public interface Requirement extends Serializable { + /** + * @return true if the requirement is satisfied, false otherwise. + */ + boolean isPresent(@NonNull Job job); + + void onRetry(@NonNull Job job); +} diff --git a/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java b/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java new file mode 100644 index 0000000000000000000000000000000000000000..d666d0ccec25c945afcbab7f1c636b619095756b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.Button; + +import androidx.appcompat.widget.AppCompatTextView; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class MessageRequestsBottomView extends ConstraintLayout { + + private AppCompatTextView question; + private Button accept; + private Button block; + + public MessageRequestsBottomView(Context context) { + super(context); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + inflate(getContext(), R.layout.message_request_bottom_bar, this); + + question = findViewById(R.id.message_request_question); + accept = findViewById(R.id.message_request_accept); + block = findViewById(R.id.message_request_block); + } + + public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) { + accept.setOnClickListener(acceptOnClickListener); + } + + public void setAcceptText(int text) { + accept.setText(text); + } + + public void setBlockOnClickListener(OnClickListener deleteOnClickListener) { + block.setOnClickListener(deleteOnClickListener); + } + + public void setBlockText(int text) { + block.setText(text); + } + + public void setQuestion(String text) { + if (text == null || text.isEmpty()) { + question.setMaxHeight(ViewUtil.dpToPx(5)); + } else { + question.setMaxHeight(Integer.MAX_VALUE); + question.setText(text); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java new file mode 100644 index 0000000000000000000000000000000000000000..c0dde8289466d8d65b81839a3e34d2cbe807ea19 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -0,0 +1,731 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.util.Log; +import android.util.Pair; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.ShareLocationDialog; +import org.thoughtcrime.securesms.WebxdcActivity; +import org.thoughtcrime.securesms.WebxdcStoreActivity; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.DocumentView; +import org.thoughtcrime.securesms.components.RemovableEditableMediaView; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.VcardView; +import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.geolocation.DcLocationManager; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.guava.Optional; +import org.thoughtcrime.securesms.util.views.Stub; + +import java.io.File; +import java.io.IOException; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import chat.delta.rpc.RpcException; +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + + +public class AttachmentManager { + + private final static String TAG = AttachmentManager.class.getSimpleName(); + + private final @NonNull Context context; + private final @NonNull Stub attachmentViewStub; + private final @NonNull + AttachmentListener attachmentListener; + + private RemovableEditableMediaView removableMediaView; + private ThumbnailView thumbnail; + private AudioView audioView; + private DocumentView documentView; + private WebxdcView webxdcView; + private VcardView vcardView; + //private SignalMapView mapView; + + private final @NonNull List garbage = new LinkedList<>(); + private @NonNull Optional slide = Optional.absent(); + private @Nullable Uri imageCaptureUri; + private @Nullable Uri videoCaptureUri; + private boolean attachmentPresent; + private boolean hidden; + + public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { + this.context = activity; + this.attachmentListener = listener; + this.attachmentViewStub = ViewUtil.findStubById(activity, R.id.attachment_editor_stub); + } + + private void inflateStub() { + if (!attachmentViewStub.resolved()) { + View root = attachmentViewStub.get(); + + this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); + this.audioView = ViewUtil.findById(root, R.id.attachment_audio); + this.documentView = ViewUtil.findById(root, R.id.attachment_document); + this.webxdcView = ViewUtil.findById(root, R.id.attachment_webxdc); + this.vcardView = ViewUtil.findById(root, R.id.attachment_vcard); + //this.mapView = ViewUtil.findById(root, R.id.attachment_location); + this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view); + + removableMediaView.setRemoveClickListener(new RemoveButtonListener()); + removableMediaView.setEditClickListener(new EditButtonListener()); + thumbnail.setOnClickListener(new ThumbnailClickListener()); + } + + } + + public void clear(@NonNull GlideRequests glideRequests, boolean animate) { + if (attachmentViewStub.resolved()) { + + if (animate) { + ViewUtil.fadeOut(attachmentViewStub.get(), 200).addListener(new ListenableFuture.Listener() { + @Override + public void onSuccess(Boolean result) { + thumbnail.clear(glideRequests); + setAttachmentPresent(false); + attachmentListener.onAttachmentChanged(); + } + + @Override + public void onFailure(ExecutionException e) { + } + }); + } else { + thumbnail.clear(glideRequests); + setAttachmentPresent(false); + attachmentListener.onAttachmentChanged(); + } + + markGarbage(getSlideUri()); + slide = Optional.absent(); + + audioView.cleanup(); + } + } + + public void cleanup() { + cleanup(imageCaptureUri); + cleanup(videoCaptureUri); + cleanup(getSlideUri()); + + imageCaptureUri = null; + videoCaptureUri = null; + slide = Optional.absent(); + + Iterator iterator = garbage.listIterator(); + + while (iterator.hasNext()) { + cleanup(iterator.next()); + iterator.remove(); + } + } + + private void cleanup(final @Nullable Uri uri) { + if (uri != null && PersistentBlobProvider.isAuthority(context, uri)) { + Log.w(TAG, "cleaning up " + uri); + PersistentBlobProvider.getInstance().delete(context, uri); + } + } + + private void markGarbage(@Nullable Uri uri) { + if (uri != null && PersistentBlobProvider.isAuthority(context, uri)) { + Log.w(TAG, "Marking garbage that needs cleaning: " + uri); + garbage.add(uri); + } + } + + private void setSlide(@NonNull Slide slide) { + if (getSlideUri() != null) cleanup(getSlideUri()); + if (imageCaptureUri != null && !imageCaptureUri.equals(slide.getUri())) cleanup(imageCaptureUri); + if (videoCaptureUri != null && !videoCaptureUri.equals(slide.getUri())) cleanup(videoCaptureUri); + + this.imageCaptureUri = null; + this.videoCaptureUri = null; + this.slide = Optional.of(slide); + } + + /* + public ListenableFuture setLocation(@NonNull final SignalPlace place, + @NonNull final MediaConstraints constraints) + { + inflateStub(); + + SettableFuture returnResult = new SettableFuture<>(); + ListenableFuture future = mapView.display(place); + + attachmentViewStub.get().setVisibility(View.VISIBLE); + removableMediaView.display(mapView, false); + + future.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(@NonNull Bitmap result) { + byte[] blob = BitmapUtil.toByteArray(result); + Uri uri = PersistentBlobProvider.getInstance(context) + .create(context, blob, MediaUtil.IMAGE_PNG, null); + LocationSlide locationSlide = new LocationSlide(context, uri, blob.length, place); + + setSlide(locationSlide); + attachmentListener.onAttachmentChanged(); + returnResult.set(true); + } + }); + + return returnResult; + } + */ + + @SuppressLint("StaticFieldLeak") + public ListenableFuture setMedia(@NonNull final GlideRequests glideRequests, + @NonNull final Uri uri, + @Nullable final DcMsg msg, + @NonNull final MediaType mediaType, + final int width, + final int height, + final int chatId) + { + inflateStub(); + + final SettableFuture result = new SettableFuture<>(); + + new AsyncTask() { + @Override + protected void onPreExecute() { + thumbnail.clear(glideRequests); + setAttachmentPresent(true); + } + + @Override + protected @Nullable Slide doInBackground(Void... params) { + try { + if (msg != null && msg.getType() == DcMsg.DC_MSG_WEBXDC) { + return new DocumentSlide(context, msg); + } + else if (PartAuthority.isLocalUri(uri)) { + return getManuallyCalculatedSlideInfo(uri, width, height, msg); + } else { + Slide result = getContentResolverSlideInfo(uri, width, height, chatId); + + if (result == null) return getManuallyCalculatedSlideInfo(uri, width, height, msg); + else return result; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + protected void onPostExecute(@Nullable final Slide slide) { + if (slide == null) { + setAttachmentPresent(false); + result.set(false); + } else if (slide.getFileSize() > 1024 * 1024 * 1024) { + // this is only a rough check, videos and images may be recoded + // and the core checks more carefully later. + setAttachmentPresent(false); + Log.w(TAG, "File too large."); + Toast.makeText(slide.context, "File too large.", Toast.LENGTH_LONG).show(); + result.set(false); + } else { + setSlide(slide); + setAttachmentPresent(true); + + if (slide.hasAudio()) { + class SetDurationListener implements AudioSlidePlayer.Listener { + @Override + public void onStart() {} + + @Override + public void onStop() {} + + @Override + public void onProgress(AudioSlide slide, double progress, long millis) {} + + @Override + public void onReceivedDuration(int millis) { + ((AudioView) removableMediaView.getCurrent()).setDuration(millis); + } + } + AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(context, (AudioSlide) slide, new SetDurationListener()); + audioSlidePlayer.requestDuration(); + + audioView.setAudio((AudioSlide) slide, 0); + removableMediaView.display(audioView, false); + result.set(true); + } else if (slide.isVcard()) { + vcardView.setVcard(glideRequests, (VcardSlide)slide, DcHelper.getRpc(context)); + removableMediaView.display(vcardView, false); + } else if (slide.hasDocument()) { + if (slide.isWebxdcDocument()) { + DcMsg instance = msg != null ? msg : DcHelper.getContext(context).getMsg(slide.dcMsgId); + webxdcView.setWebxdc(instance, context.getString(R.string.webxdc_draft_hint)); + webxdcView.setWebxdcClickListener((v, s) -> { + WebxdcActivity.openWebxdcActivity(context, instance); + }); + removableMediaView.display(webxdcView, false); + } else { + documentView.setDocument((DocumentSlide) slide); + removableMediaView.display(documentView, false); + } + result.set(true); + } else { + Attachment attachment = slide.asAttachment(); + result.deferTo(thumbnail.setImageResource(glideRequests, slide, attachment.getWidth(), attachment.getHeight())); + removableMediaView.display(thumbnail, mediaType == MediaType.IMAGE); + } + + attachmentListener.onAttachmentChanged(); + } + } + + private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height, int chatId) { + + long start = System.currentTimeMillis(); + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + String mimeType = context.getContentResolver().getType(uri); + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.w(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, fileSize, width, height, chatId); + } + } + + return null; + } + + private @NonNull Slide getManuallyCalculatedSlideInfo(Uri uri, int width, int height, @Nullable DcMsg msg) throws IOException { + long start = System.currentTimeMillis(); + Long mediaSize = null; + String fileName = null; + String mimeType = null; + + if (msg != null) { + fileName = msg.getFilename(); + mimeType = msg.getFilemime(); + } + + if (PartAuthority.isLocalUri(uri)) { + mediaSize = PartAuthority.getAttachmentSize(context, uri); + if (fileName == null) fileName = PartAuthority.getAttachmentFileName(context, uri); + if (mimeType == null) mimeType = PartAuthority.getAttachmentContentType(context, uri); + } + + if (mediaSize == null) { + mediaSize = MediaUtil.getMediaSize(context, uri); + } + + if (mimeType == null) { + mimeType = MediaUtil.getMimeType(context, uri); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + if (fileName == null) { + try { + fileName = new File(uri.getPath()).getName(); + } catch(Exception e) { + Log.w(TAG, "Could not get file name from uri: " + e); + } + } + + Log.w(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, mediaSize, width, height, chatId); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return result; + } + + // should be called when the attachment manager comes into view again. + // if the attachment manager contains a webxdc, its summary is updated. + public void onResume() { + if (slide.isPresent()) { + if (slide.get().isWebxdcDocument()) { + if (webxdcView != null) { + webxdcView.setWebxdc(DcHelper.getContext(context).getMsg(slide.get().dcMsgId), context.getString(R.string.webxdc_draft_hint)); + } + } + } + } + + public boolean isAttachmentPresent() { + return attachmentPresent; + } + + public @NonNull SlideDeck buildSlideDeck() { + SlideDeck deck = new SlideDeck(); + if (slide.isPresent()) deck.addSlide(slide.get()); + return deck; + } + + public static @Nullable String getFileName(Context context, Uri uri) { + String result = null; + if ("content".equals(uri.getScheme())) { + try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + result = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + } + } + } + if (result == null) { + result = uri.getLastPathSegment(); + } + return result; + } + + public static void selectDocument(Activity activity, int requestCode) { + selectMediaType(activity, "*/*", null, requestCode); + } + + public static void selectWebxdc(Activity activity, int requestCode) { + Intent intent = new Intent(activity, WebxdcStoreActivity.class); + activity.startActivityForResult(intent, requestCode); + } + + public static void selectGallery(Activity activity, int requestCode) { + // to enable camera roll, + // we're asking for "gallery permissions" also on newer systems that do not strictly require that. + // (asking directly after tapping "attachment" would be not-so-good as the user may want to attach sth. else + // and asking for permissions is better done on-point) + Permissions.with(activity) + .request(Permissions.galleryPermissions()) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode, null, true)) + .execute(); + } + + public static void selectImage(Activity activity, int requestCode) { + Permissions.with(activity) + .request(Permissions.galleryPermissions()) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> selectMediaType(activity, "image/*", null, requestCode)) + .execute(); + } + + public static void selectLocation(Activity activity, int chatId) { + ApplicationContext applicationContext = ApplicationContext.getInstance(activity); + DcLocationManager dcLocationManager = applicationContext.dcLocationManager; + + if (DcHelper.getContext(applicationContext).isSendingLocationsToChat(chatId)) { + dcLocationManager.stopSharingLocation(chatId); + return; + } + + // see https://support.google.com/googleplay/android-developer/answer/9799150#zippy=%2Cstep-provide-prominent-in-app-disclosure + // for rationale dialog requirements + Permissions.PermissionsBuilder permissionsBuilder = Permissions.with(activity) + .ifNecessary() + .withRationaleDialog("To share your live location with chat members, allow ArcaneChat to use your location data.\n\nTo make live location work gaplessly, location data is used even when the app is closed or not in use.", R.drawable.ic_location_on_white_24dp) + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_location_denied)) + .onAllGranted(() -> { + ShareLocationDialog.show(activity, durationInSeconds -> { + if (durationInSeconds == 1) { + dcLocationManager.shareLastLocation(chatId); + } else { + dcLocationManager.shareLocation(durationInSeconds, chatId); + } + }); + }); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + permissionsBuilder.request(Manifest.permission.ACCESS_BACKGROUND_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION); + } else { + permissionsBuilder.request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION); + } + permissionsBuilder.execute(); + } + + private @Nullable Uri getSlideUri() { + return slide.isPresent() ? slide.get().getUri() : null; + } + + public @Nullable Uri getImageCaptureUri() { + return imageCaptureUri; + } + + public @Nullable Uri getVideoCaptureUri() { + return videoCaptureUri; + } + + public void capturePhoto(Activity activity, int requestCode) { + Permissions.with(activity) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_camera_denied)) + .onAllGranted(() -> { + try { + Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { + if (imageCaptureUri == null) { + imageCaptureUri = PersistentBlobProvider.getInstance().createForExternal(context, MediaUtil.IMAGE_JPEG); + } + Log.w(TAG, "imageCaptureUri path is " + imageCaptureUri.getPath()); + captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageCaptureUri); + activity.startActivityForResult(captureIntent, requestCode); + } + } catch (Exception e) { + Log.w(TAG, e); + } + }) + .execute(); + } + + public void captureVideo(Activity activity, int requestCode) { + Permissions.with(activity) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_camera_denied)) + .onAllGranted(() -> { + try { + Intent captureIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); + if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { + if (videoCaptureUri==null) { + videoCaptureUri = PersistentBlobProvider.getInstance().createForExternal(context, "video/mp4"); + } + Log.w(TAG, "videoCaptureUri path is " + videoCaptureUri.getPath()); + captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoCaptureUri); + captureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + captureIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivityForResult(captureIntent, requestCode); + } else { + new AlertDialog.Builder(activity) + .setCancelable(false) + .setMessage("Video recording not available") + .setPositiveButton(android.R.string.ok, null) + .show(); + } + } catch (Exception e) { + Log.w(TAG, e); + } + }) + .execute(); + } + + public static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + selectMediaType(activity, type, extraMimeType, requestCode, null, false); + } + + public static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode, @Nullable Uri initialUri) { + selectMediaType(activity, type, extraMimeType, requestCode, initialUri, false); + } + + public static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode, @Nullable Uri initialUri, boolean allowMultiple) { + final Intent intent = new Intent(); + intent.setType(type); + + if (extraMimeType != null) { + intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + } + + if (initialUri != null && Build.VERSION.SDK_INT >= 26) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri); + } + + if (allowMultiple) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + intent.setAction(Intent.ACTION_OPEN_DOCUMENT); + try { + activity.startActivityForResult(intent, requestCode); + return; + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_OPEN_DOCUMENT, no activity found. falling back."); + } + + intent.setAction(Intent.ACTION_GET_CONTENT); + + try { + activity.startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_GET_CONTENT intent, no activity found. falling back."); + Toast.makeText(activity, R.string.no_app_to_handle_data, Toast.LENGTH_LONG).show(); + } + } + + private void previewImageDraft(final @NonNull Slide slide) { + if (MediaPreviewActivity.isTypeSupported(slide) && slide.getUri() != null) { + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(MediaPreviewActivity.DC_MSG_ID, slide.getDcMsgId()); + intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, true); + intent.setDataAndType(slide.getUri(), slide.getContentType()); + + context.startActivity(intent); + } + } + + private class ThumbnailClickListener implements View.OnClickListener { + @Override + public void onClick(View v) { + if (slide.isPresent()) previewImageDraft(slide.get()); + } + } + + private class RemoveButtonListener implements View.OnClickListener { + @Override + public void onClick(View v) { + cleanup(); + clear(GlideApp.with(context.getApplicationContext()), true); + } + } + + private class EditButtonListener implements View.OnClickListener { + @Override + public void onClick(View v) { + Uri imgUri = getSlideUri(); + if (imgUri != null) { + Intent intent = new Intent(context, ScribbleActivity.class); + intent.setData(imgUri); + ((Activity) context).startActivityForResult(intent, ScribbleActivity.SCRIBBLE_REQUEST_CODE); + } + } + } + + public interface AttachmentListener { + void onAttachmentChanged(); + } + + public enum MediaType { + IMAGE, GIF, AUDIO, VIDEO, DOCUMENT; + + public @NonNull Slide createSlide(@NonNull Context context, + @NonNull Uri uri, + @Nullable String fileName, + @Nullable String mimeType, + long dataSize, + int width, + int height, + int chatId) + { + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + + switch (this) { + case IMAGE: return new ImageSlide(context, uri, fileName, dataSize, width, height); + case GIF: return new GifSlide(context, uri, fileName, dataSize, width, height); + case AUDIO: return new AudioSlide(context, uri, dataSize, false, fileName); + case VIDEO: return new VideoSlide(context, uri, fileName, dataSize); + case DOCUMENT: + // We have to special-case Webxdc slides: The user can interact with them as soon as a draft + // is set. Therefore we need to create a DcMsg already now. + if (fileName != null && fileName.endsWith(".xdc")) { + DcContext dcContext = DcHelper.getContext(context); + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_WEBXDC); + Attachment attachment = new UriAttachment(uri, null, MediaUtil.WEBXDC, AttachmentDatabase.TRANSFER_PROGRESS_STARTED, 0, 0, 0, fileName, null, false); + String path = attachment.getRealPath(context); + msg.setFileAndDeduplicate(path, fileName, MediaUtil.WEBXDC); + dcContext.setDraft(chatId, msg); + return new DocumentSlide(context, msg); + } + + if (mimeType.equals(MediaUtil.VCARD) || (fileName != null && (fileName.endsWith(".vcf") || fileName.endsWith(".vcard")))) { + VcardSlide slide = new VcardSlide(context, uri, dataSize, fileName); + String path = slide.asAttachment().getRealPath(context); + try { + if (DcHelper.getRpc(context).parseVcard(path).size() == 1) { + return slide; + } + } catch (RpcException e) { + Log.e(TAG, "Error in call to rpc.parseVcard()", e); + } + } + + return new DocumentSlide(context, uri, mimeType, dataSize, fileName); + default: throw new AssertionError("unrecognized enum"); + } + } + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + updateVisibility(); + } + + private void setAttachmentPresent(boolean isPresent) { + this.attachmentPresent = isPresent; + updateVisibility(); + } + + private void updateVisibility() { + int vis; + if (attachmentPresent && !hidden) { + vis = View.VISIBLE; + } else { + vis = View.GONE; + } + if (vis == View.GONE && !attachmentViewStub.resolved()) { + return; + } + attachmentViewStub.get().setVisibility(vis); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..48a9a904b54468949d02b1292223228777f58275 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.DcAttachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.StorageUtil; + + +public class AudioSlide extends Slide { + + public AudioSlide(Context context, DcMsg dcMsg) { + super(context, new DcAttachment(dcMsg)); + dcMsgId = dcMsg.getId(); + } + + public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote, String fileName) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, null, StorageUtil.getCleanFileName(fileName), voiceNote)); + } + + public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) { + super(context, new UriAttachment(uri, null, contentType, AttachmentDatabase.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote)); + } + + @Override + public boolean equals(Object other) { + if (other == null) return false; + if (!(other instanceof AudioSlide)) return false; + return this.getDcMsgId() == ((AudioSlide)other).getDcMsgId(); + } + + @Override + @Nullable + public Uri getThumbnailUri() { + return null; + } + + @Override + public boolean hasAudio() { + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java b/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java new file mode 100644 index 0000000000000000000000000000000000000000..1a1cacf4fe312a8b2e66039ab45bf4c28a13e793 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import com.bumptech.glide.load.data.StreamLocalUriFetcher; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher { + + private static final String TAG = DecryptableStreamLocalUriFetcher.class.getSimpleName(); + + private final Context context; + + DecryptableStreamLocalUriFetcher(Context context, Uri uri) { + super(context.getContentResolver(), uri); + this.context = context; + } + + @Override + protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException { + try { + return PartAuthority.getAttachmentStream(context, uri); + } catch (IOException ioe) { + Log.w(TAG, ioe); + throw new FileNotFoundException("PartAuthority couldn't load Uri resource."); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java b/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..4f136c8ca594df7dbe1c58cb284456b9774bfe69 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; + +import java.io.InputStream; +import java.security.MessageDigest; + +public class DecryptableStreamUriLoader implements ModelLoader { + + private final Context context; + + private DecryptableStreamUriLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData buildLoadData(@NonNull DecryptableUri decryptableUri, int width, int height, @NonNull Options options) { + return new LoadData<>(decryptableUri, new DecryptableStreamLocalUriFetcher(context, decryptableUri.uri)); + } + + @Override + public boolean handles(@NonNull DecryptableUri decryptableUri) { + return true; + } + + static class Factory implements ModelLoaderFactory { + + private final Context context; + + Factory(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new DecryptableStreamUriLoader(context); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + public static class DecryptableUri implements Key { + public final @NonNull Uri uri; + + public DecryptableUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(uri.toString().getBytes()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DecryptableUri that = (DecryptableUri)o; + + return uri.equals(that.uri); + + } + + @Override + public int hashCode() { + return uri.hashCode(); + } + } +} + diff --git a/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..6b495947fdb01fbad9306f5474ff0e3d1c2f3c59 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.DcAttachment; +import org.thoughtcrime.securesms.util.StorageUtil; + +public class DocumentSlide extends Slide { + private int dcMsgType = DcMsg.DC_MSG_UNDEFINED; + + public DocumentSlide(Context context, DcMsg dcMsg) { + super(context, new DcAttachment(dcMsg)); + dcMsgId = dcMsg.getId(); + dcMsgType = dcMsg.getType(); + } + + public DocumentSlide(@NonNull Context context, @NonNull Uri uri, + @NonNull String contentType, long size, + @Nullable String fileName) + { + super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, uri, StorageUtil.getCleanFileName(fileName), false)); + } + + @Override + public boolean hasDocument() { + return true; + } + + @Override + public boolean isWebxdcDocument() { + return dcMsgType == DcMsg.DC_MSG_WEBXDC; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..2811be6e3df40ce94813c4a3a79122365741b38c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.util.MediaUtil; + +public class GifSlide extends ImageSlide { + + public GifSlide(Context context, DcMsg dcMsg) { + super(context, dcMsg); + } + + public GifSlide(Context context, Uri uri, String fileName, long size, int width, int height) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, uri, fileName, false)); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..7058222820e9489c9368ba74194d732d06305fa2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DcAttachment; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class ImageSlide extends Slide { + + public ImageSlide(@NonNull Context context, @NonNull DcMsg dcMsg) { + super(context, new DcAttachment(dcMsg)); + dcMsgId = dcMsg.getId(); + } + + public ImageSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + } + + public ImageSlide(Context context, Uri uri, String fileName, long size, int width, int height) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, uri, fileName, false)); + } + + @Override + public @Nullable Uri getThumbnailUri() { + return getUri(); + } + + @Override + public boolean hasImage() { + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java new file mode 100644 index 0000000000000000000000000000000000000000..01ccd93576dfb5b2afbf2960c28d7e510c1ba205 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; + +public abstract class MediaConstraints { + public abstract int getImageMaxWidth(Context context); + public abstract int getImageMaxHeight(Context context); + public abstract int getImageMaxSize(Context context); +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java new file mode 100644 index 0000000000000000000000000000000000000000..b0ed5a7f17645accda540ceaeab79e0deb3066a7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.ContentUris; +import android.content.Context; +import android.content.UriMatcher; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.providers.SingleUseBlobProvider; + +import java.io.IOException; +import java.io.InputStream; + +public class PartAuthority { + + private static final int PERSISTENT_ROW = 3; + private static final int SINGLE_USE_ROW = 4; + + private static final UriMatcher uriMatcher; + + static { + uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + uriMatcher.addURI(PersistentBlobProvider.AUTHORITY, PersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW); + uriMatcher.addURI(PersistentBlobProvider.AUTHORITY, PersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW); + uriMatcher.addURI(SingleUseBlobProvider.AUTHORITY, SingleUseBlobProvider.PATH, SINGLE_USE_ROW); + } + + public static InputStream getAttachmentStream(@NonNull Context context, @NonNull Uri uri) + throws IOException + { + int match = uriMatcher.match(uri); + try { + switch (match) { + case PERSISTENT_ROW: return PersistentBlobProvider.getInstance().getStream(context, ContentUris.parseId(uri)); + case SINGLE_USE_ROW: return SingleUseBlobProvider.getInstance().getStream(ContentUris.parseId(uri)); + default: return context.getContentResolver().openInputStream(uri); + } + } catch (SecurityException se) { + throw new IOException(se); + } + } + + public static @Nullable String getAttachmentFileName(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PERSISTENT_ROW: + return PersistentBlobProvider.getFileName(context, uri); + case SINGLE_USE_ROW: + default: + return null; + } + } + + public static @Nullable Long getAttachmentSize(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PERSISTENT_ROW: + return PersistentBlobProvider.getFileSize(context, uri); + case SINGLE_USE_ROW: + default: + return null; + } + } + + public static @Nullable String getAttachmentContentType(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PERSISTENT_ROW: + return PersistentBlobProvider.getMimeType(context, uri); + case SINGLE_USE_ROW: + default: + return null; + } + } + + public static boolean isLocalUri(final @NonNull Uri uri) { + int match = uriMatcher.match(uri); + switch (match) { + case PERSISTENT_ROW: + case SINGLE_USE_ROW: + return true; + } + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java b/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java new file mode 100644 index 0000000000000000000000000000000000000000..3b6655c618c4614d350012f3b524f4d61dabd9f3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.mms; + + +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.Attachment; + +import java.util.List; + +public class QuoteModel { + + private final DcContact author; + private final String text; + private final List attachments; + private final DcMsg quotedMsg; + + public QuoteModel(DcContact author, String text, @Nullable List attachments, DcMsg quotedMsg) { + this.author = author; + this.text = text; + this.attachments = attachments; + this.quotedMsg = quotedMsg; + } + + public DcContact getAuthor() { + return author; + } + + public String getText() { + return text; + } + + public List getAttachments() { + return attachments; + } + + public DcMsg getQuotedMsg() { + return quotedMsg; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java new file mode 100644 index 0000000000000000000000000000000000000000..fabe6e5f0df11d0be594f67b9e77fcc47183d741 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.graphics.drawable.PictureDrawable; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.airbnb.lottie.LottieComposition; +import com.airbnb.lottie.LottieDrawable; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.load.model.UnitModelLoader; +import com.bumptech.glide.module.AppGlideModule; + +import com.caverock.androidsvg.SVG; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.glide.ContactPhotoLoader; +import org.thoughtcrime.securesms.glide.lottie.LottieDecoder; +import org.thoughtcrime.securesms.glide.lottie.LottieDrawableTranscoder; +import org.thoughtcrime.securesms.glide.svg.SvgDecoder; +import org.thoughtcrime.securesms.glide.svg.SvgDrawableTranscoder; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; + +import java.io.File; +import java.io.InputStream; + +@GlideModule +public class SignalGlideModule extends AppGlideModule { + + @Override + public boolean isManifestParsingEnabled() { + return false; + } + + @Override + public void applyOptions(@NonNull Context context, GlideBuilder builder) { + builder.setLogLevel(Log.ERROR); +// builder.setDiskCache(new NoopDiskCacheFactory()); + } + + @Override + public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + //AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + //byte[] secret = attachmentSecret.getModernKey(); + + registry.prepend(File.class, File.class, UnitModelLoader.Factory.getInstance()); + //registry.prepend(InputStream.class, new EncryptedCacheEncoder(secret, glide.getArrayPool())); + //registry.prepend(File.class, Bitmap.class, new EncryptedBitmapCacheDecoder(secret, new StreamBitmapDecoder(new Downsampler(registry.getImageHeaderParsers(), context.getResources().getDisplayMetrics(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool()))); + //registry.prepend(File.class, GifDrawable.class, new EncryptedGifCacheDecoder(secret, new StreamGifDecoder(registry.getImageHeaderParsers(), new ByteBufferGifDecoder(context, registry.getImageHeaderParsers(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool()))); + + //registry.prepend(Bitmap.class, new EncryptedBitmapResourceEncoder(secret)); + //registry.prepend(GifDrawable.class, new EncryptedGifDrawableResourceEncoder(secret)); + + registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context)); + registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); + //registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); + + registry + .register(LottieComposition.class, LottieDrawable.class, new LottieDrawableTranscoder()) + .append(InputStream.class, LottieComposition.class, new LottieDecoder()); + + registry + .register(SVG.class, PictureDrawable.class, new SvgDrawableTranscoder()) + .append(InputStream.class, SVG.class, new SvgDecoder()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/src/main/java/org/thoughtcrime/securesms/mms/Slide.java new file mode 100644 index 0000000000000000000000000000000000000000..4b3c1ea9aa4fb440f54756866b4a7bdcf173acd1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -0,0 +1,175 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.guava.Optional; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +public abstract class Slide { + + protected int dcMsgId; + protected final Attachment attachment; + protected final Context context; + + public Slide(@NonNull Context context, @NonNull Attachment attachment) { + this.context = context; + this.attachment = attachment; + + } + + public int getDcMsgId() { + return dcMsgId; + } + + public String getContentType() { + return attachment.getContentType(); + } + + @Nullable + public Uri getUri() { + return attachment.getDataUri(); + } + + @Nullable + public Uri getThumbnailUri() { + return attachment.getThumbnailUri(); + } + + @NonNull + public Optional getFileName() { + return Optional.fromNullable(attachment.getFileName()); + } + + @Nullable + public String getFastPreflightId() { + return attachment.getFastPreflightId(); + } + + public long getFileSize() { + return attachment.getSize(); + } + + /* Return true if this slide has a thumbnail when being quoted, false otherwise */ + public boolean hasQuoteThumbnail() { + return (hasImage() || hasVideo() || hasSticker() || isWebxdcDocument() || isVcard()) && getUri() != null; + } + + public boolean hasImage() { + return false; + } + + public boolean hasSticker() { return false; } + + public boolean hasVideo() { + return false; + } + + public boolean hasAudio() { + return false; + } + + public boolean hasDocument() { + return false; + } + + public boolean isWebxdcDocument() { + return false; + } + + public boolean isVcard() { + return false; + } + + public boolean hasLocation() { + return false; + } + + public Attachment asAttachment() { + return attachment; + } + + public long getTransferState() { + return attachment.getTransferState(); + } + + public boolean hasPlayOverlay() { + return false; + } + + protected static Attachment constructAttachmentFromUri(@NonNull Context context, + @NonNull Uri uri, + @NonNull String defaultMime, + long size, + int width, + int height, + @Nullable Uri thumbnailUri, + @Nullable String fileName, + boolean voiceNote) + { + try { + String resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime); + String fastPreflightId = String.valueOf(SecureRandom.getInstance("SHA1PRNG").nextLong()); + return new UriAttachment(uri, + thumbnailUri, + resolvedType, + AttachmentDatabase.TRANSFER_PROGRESS_STARTED, + size, + width, + height, + fileName, + fastPreflightId, + voiceNote); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @Override + public boolean equals(Object other) { + if (other == null) return false; + if (!(other instanceof Slide)) return false; + + Slide that = (Slide)other; + + return Util.equals(this.getContentType(), that.getContentType()) && + this.hasAudio() == that.hasAudio() && + this.hasImage() == that.hasImage() && + this.hasVideo() == that.hasVideo() && + this.getTransferState() == that.getTransferState() && + Util.equals(this.getUri(), that.getUri()) && + Util.equals(this.getThumbnailUri(), that.getThumbnailUri()); + } + + @Override + public int hashCode() { + return Util.hashCode(getContentType(), hasAudio(), hasImage(), + hasVideo(), getUri(), getThumbnailUri(), getTransferState()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java b/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java new file mode 100644 index 0000000000000000000000000000000000000000..f7d1345ca017f99293aafea55407f835f57fc857 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.mms; + +import android.view.View; + +public interface SlideClickListener { + void onClick(View v, Slide slide); +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java b/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java new file mode 100644 index 0000000000000000000000000000000000000000..beee26df8455fb90b7216770b5c7a11019df106f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -0,0 +1,85 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; + +import java.util.LinkedList; +import java.util.List; + +public class SlideDeck { + + private final List slides = new LinkedList<>(); + + public SlideDeck() { + } + + public void clear() { + slides.clear(); + } + + @NonNull + public List asAttachments() { + List attachments = new LinkedList<>(); + + for (Slide slide : slides) { + attachments.add(slide.asAttachment()); + } + + return attachments; + } + + public void addSlide(Slide slide) { + slides.add(slide); + } + + public List getSlides() { + return slides; + } + + public boolean containsMediaSlide() { + for (Slide slide : slides) { + if (slide.hasImage() || slide.hasVideo() || slide.hasAudio() || slide.hasDocument()) { + return true; + } + } + return false; + } + + public @Nullable DocumentSlide getDocumentSlide() { + for (Slide slide: slides) { + if (slide.hasDocument()) { + return (DocumentSlide)slide; + } + } + + return null; + } + + // Webxdc requires draft-ids to be used; this function returns the previously used draft-id, if any. + public int getWebxdctDraftId() { + for (Slide slide: slides) { + if (slide.isWebxdcDocument()) { + return slide.dcMsgId; + } + } + return 0; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..356bc9f42be5d84c3cdb1d4f4a10b2cbcf8e9cc0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.DcAttachment; + + +public class StickerSlide extends Slide { + + public StickerSlide(@NonNull Context context, @NonNull DcMsg dcMsg) { + super(context, new DcAttachment(dcMsg)); + } + + @Override + public boolean hasSticker() { + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/VcardSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/VcardSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..01f1d76252bb0848454666a5dcb1ebb421060ab9 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/VcardSlide.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcMsg; + +public class VcardSlide extends DocumentSlide { + + public VcardSlide(Context context, DcMsg dcMsg) { + super(context, dcMsg); + } + + public VcardSlide(@NonNull Context context, @NonNull Uri uri, long size, @Nullable String fileName) { + super(context, uri, "text/vcard", size, fileName); + } + + @Override + public boolean isVcard() { + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java new file mode 100644 index 0000000000000000000000000000000000000000..c8d02b2b3690b742dd2423740bef2ae88e7e9f4a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DcAttachment; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.File; + +public class VideoSlide extends Slide { + + private static Attachment constructVideoAttachment(Context context, Uri uri, String fileName, long dataSize) + { + Uri thumbnailUri = Uri.fromFile(new File(DcHelper.getBlobdirFile(DcHelper.getContext(context), "temp-preview.jpg"))); + MediaUtil.ThumbnailSize retWh = new MediaUtil.ThumbnailSize(0, 0); + MediaUtil.createVideoThumbnailIfNeeded(context, uri, thumbnailUri, retWh); + return constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, retWh.width, retWh.height, thumbnailUri, fileName, false); + } + + public VideoSlide(Context context, Uri uri, String fileName, long dataSize) { + super(context, constructVideoAttachment(context, uri, fileName, dataSize)); + } + + public VideoSlide(Context context, DcMsg dcMsg) { + super(context, new DcAttachment(dcMsg)); + dcMsgId = dcMsg.getId(); + } + + @Override + public boolean hasPlayOverlay() { + return true; + } + + @Override + public boolean hasImage() { + return true; + } + + @Override + public boolean hasVideo() { + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java b/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..d0ef7242659953e23ccd6cfd16b8ad523ef0cbba --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + +import chat.delta.rpc.RpcException; + +public class DeclineCallReceiver extends BroadcastReceiver { + private static final String TAG = DeclineCallReceiver.class.getSimpleName(); + + public static final String DECLINE_ACTION = "org.thoughtcrime.securesms.notifications.DECLINE_CALL"; + public static final String ACCOUNT_ID_EXTRA = "account_id"; + public static final String CALL_ID_EXTRA = "call_id"; + + @Override + public void onReceive(final Context context, Intent intent) { + if (!DECLINE_ACTION.equals(intent.getAction())) { + return; + } + + final int accountId = intent.getIntExtra(ACCOUNT_ID_EXTRA, 0); + final int callId = intent.getIntExtra(CALL_ID_EXTRA, 0); + if (accountId == 0) { + return; + } + + Util.runOnAnyBackgroundThread(() -> { + DcHelper.getNotificationCenter(context).removeCallNotification(accountId, callId); + try { + DcHelper.getRpc(context).endCall(accountId, callId); + } catch (RpcException e) { + Log.e(TAG, "Error", e); + } + }); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/InChatSounds.java b/src/main/java/org/thoughtcrime/securesms/notifications/InChatSounds.java new file mode 100644 index 0000000000000000000000000000000000000000..373d7bc09e47839da0fbf1be89058f1d8c47138b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/notifications/InChatSounds.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.SoundPool; +import android.util.Log; + +import org.thoughtcrime.securesms.R; + +public class InChatSounds { + private static final String TAG = InChatSounds.class.getSimpleName(); + private static volatile InChatSounds instance; + + private SoundPool soundPool = null; + private int soundIn = 0; + private int soundOut = 0; + + static public InChatSounds getInstance(Context context) { + if (instance == null) { + synchronized (InChatSounds.class) { + if (instance == null) { + instance = new InChatSounds(context); + } + } + } + return instance; + } + + private InChatSounds(Context context) { + try { + AudioAttributes audioAttrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(); + soundPool = new SoundPool.Builder().setMaxStreams(3).setAudioAttributes(audioAttrs).build(); + soundIn = soundPool.load(context, R.raw.sound_in, 1); + soundOut = soundPool.load(context, R.raw.sound_out, 1); + } catch(Exception e) { + Log.e(TAG, "cannot initialize sounds", e); + } + } + + public void playSendSound() { + try { + soundPool.play(soundOut, 1.0f, 1.0f, 1, 0, 1.0f); + } catch(Exception e) { + Log.e(TAG, "cannot play send sound", e); + } + } + + public void playIncomingSound() { + try { + soundPool.play(soundIn, 1.0f, 1.0f, 1, 0, 1.0f); + } catch(Exception e) { + Log.e(TAG, "cannot play incoming sound", e); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..3e569c7441a97bace5a7cbf860b517c598a66016 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -0,0 +1,46 @@ +// called when the user click the "clear" or "mark read" button in the system notification + +package org.thoughtcrime.securesms.notifications; + +import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + +public class MarkReadReceiver extends BroadcastReceiver { + public static final String MARK_NOTICED_ACTION = "org.thoughtcrime.securesms.notifications.MARK_NOTICED"; + public static final String CANCEL_ACTION = "org.thoughtcrime.securesms.notifications.CANCEL"; + public static final String ACCOUNT_ID_EXTRA = "account_id"; + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String MSG_ID_EXTRA = "msg_id"; + + @Override + public void onReceive(final Context context, Intent intent) { + boolean markNoticed = MARK_NOTICED_ACTION.equals(intent.getAction()); + if (!markNoticed && !CANCEL_ACTION.equals(intent.getAction())) { + return; + } + + final int accountId = intent.getIntExtra(ACCOUNT_ID_EXTRA, 0); + final int chatId = intent.getIntExtra(CHAT_ID_EXTRA, DC_CHAT_NO_CHAT); + final int msgId = intent.getIntExtra(MSG_ID_EXTRA, 0); + if (accountId == 0 || chatId == DC_CHAT_NO_CHAT) { + return; + } + + Util.runOnAnyBackgroundThread(() -> { + DcHelper.getNotificationCenter(context).removeNotifications(accountId, chatId); + if (markNoticed) { + DcContext dcContext = DcHelper.getAccounts(context).getAccount(accountId); + dcContext.marknoticedChat(chatId); + dcContext.markseenMsgs(new int[]{msgId}); + } + }); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java b/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java new file mode 100644 index 0000000000000000000000000000000000000000..09e94c25005e0c23a53b420b74b48126d4b8556c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java @@ -0,0 +1,866 @@ +package org.thoughtcrime.securesms.notifications; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PRIVATE_TAG; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.media.AudioAttributes; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.RemoteInput; +import androidx.core.app.TaskStackBuilder; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.ConversationActivity; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.Pair; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.calls.CallActivity; + +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class NotificationCenter { + private static final String TAG = NotificationCenter.class.getSimpleName(); + @NonNull private final ApplicationContext context; + private volatile ChatData visibleChat = null; + private volatile Pair visibleWebxdc = null; + private volatile long lastAudibleNotification = 0; + private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); + + // Map, contains the last lines of each chat for each account + private final HashMap>> inboxes = new HashMap<>(); + + public NotificationCenter(Context context) { + this.context = ApplicationContext.getInstance(context); + } + + private @Nullable Uri effectiveSound(ChatData chatData) { // chatData=null: return app-global setting + if (chatData == null) { + chatData = new ChatData(0, 0); + } + @Nullable Uri chatRingtone = Prefs.getChatRingtone(context, chatData.accountId, chatData.chatId); + if (chatRingtone!=null) { + return chatRingtone; + } else { + @NonNull Uri appDefaultRingtone = Prefs.getNotificationRingtone(context); + if (!TextUtils.isEmpty(appDefaultRingtone.toString())) { + return appDefaultRingtone; + } + } + return null; + } + + private boolean effectiveVibrate(ChatData chatData) { // chatData=null: return app-global setting + if (chatData == null) { + chatData = new ChatData(0, 0); + } + Prefs.VibrateState vibrate = Prefs.getChatVibrate(context, chatData.accountId, chatData.chatId); + if (vibrate == Prefs.VibrateState.ENABLED) { + return true; + } else if (vibrate == Prefs.VibrateState.DISABLED) { + return false; + } + return Prefs.isNotificationVibrateEnabled(context); + } + + private boolean requiresIndependentChannel(ChatData chatData) { + if (chatData == null) { + chatData = new ChatData(0, 0); + } + return Prefs.getChatRingtone(context, chatData.accountId, chatData.chatId) != null + || Prefs.getChatVibrate(context, chatData.accountId, chatData.chatId) != Prefs.VibrateState.DEFAULT; + } + + private int getLedArgb(String ledColor) { + int argb; + try { + argb = Color.parseColor(ledColor); + } + catch (Exception e) { + argb = Color.rgb(0xFF, 0xFF, 0xFF); + } + return argb; + } + + private PendingIntent getOpenChatlistIntent(int accountId) { + Intent intent = new Intent(context, ConversationListActivity.class); + intent.putExtra(ConversationListActivity.ACCOUNT_ID_EXTRA, accountId); + intent.putExtra(ConversationListActivity.CLEAR_NOTIFICATIONS, true); + intent.setData(Uri.parse("custom://"+accountId)); + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + private PendingIntent getOpenChatIntent(ChatData chatData) { + Intent intent = new Intent(context, ConversationActivity.class); + intent.putExtra(ConversationActivity.ACCOUNT_ID_EXTRA, chatData.accountId); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatData.chatId); + intent.setData(Uri.parse("custom://"+chatData.accountId+"."+chatData.chatId)); + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + private PendingIntent getRemoteReplyIntent(ChatData chatData, int msgId) { + Intent intent = new Intent(RemoteReplyReceiver.REPLY_ACTION); + intent.setClass(context, RemoteReplyReceiver.class); + intent.setData(Uri.parse("custom://"+chatData.accountId+"."+chatData.chatId)); + intent.putExtra(RemoteReplyReceiver.ACCOUNT_ID_EXTRA, chatData.accountId); + intent.putExtra(RemoteReplyReceiver.CHAT_ID_EXTRA, chatData.chatId); + intent.putExtra(RemoteReplyReceiver.MSG_ID_EXTRA, msgId); + intent.setPackage(context.getPackageName()); + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + private PendingIntent getMarkAsReadIntent(ChatData chatData, int msgId, boolean markNoticed) { + Intent intent = new Intent(markNoticed? MarkReadReceiver.MARK_NOTICED_ACTION : MarkReadReceiver.CANCEL_ACTION); + intent.setClass(context, MarkReadReceiver.class); + intent.setData(Uri.parse("custom://"+chatData.accountId+"."+chatData.chatId)); + intent.putExtra(MarkReadReceiver.ACCOUNT_ID_EXTRA, chatData.accountId); + intent.putExtra(MarkReadReceiver.CHAT_ID_EXTRA, chatData.chatId); + intent.putExtra(MarkReadReceiver.MSG_ID_EXTRA, msgId); + intent.setPackage(context.getPackageName()); + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + public PendingIntent getOpenCallIntent(ChatData chatData, int callId, String payload, boolean autoAccept) { + final Intent chatIntent = new Intent(context, ConversationActivity.class) + .putExtra(ConversationActivity.ACCOUNT_ID_EXTRA, chatData.accountId) + .putExtra(ConversationActivity.CHAT_ID_EXTRA, chatData.chatId) + .setAction(Intent.ACTION_VIEW); + + String base64 = Base64.encodeToString(payload.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + String hash = ""; + try { + hash = (autoAccept? "#acceptCall=" : "#offerIncomingCall=") + URLEncoder.encode(base64, "UTF-8"); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Error", e); + } + + Intent intent = new Intent(context, CallActivity.class); + intent.setAction(autoAccept? Intent.ACTION_ANSWER : Intent.ACTION_VIEW); + intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, chatData.accountId); + intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatData.chatId); + intent.putExtra(CallActivity.EXTRA_CALL_ID, callId); + intent.putExtra(CallActivity.EXTRA_HASH, hash); + intent.setPackage(context.getPackageName()); + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(chatIntent) + .addNextIntent(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + public PendingIntent getDeclineCallIntent(ChatData chatData, int callId) { + Intent intent = new Intent(DeclineCallReceiver.DECLINE_ACTION); + intent.setClass(context, DeclineCallReceiver.class); + intent.putExtra(DeclineCallReceiver.ACCOUNT_ID_EXTRA, chatData.accountId); + intent.putExtra(DeclineCallReceiver.CALL_ID_EXTRA, callId); + intent.setPackage(context.getPackageName()); + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); + } + + // Groups and Notification channel groups + // -------------------------------------------------------------------------------------------- + + // this is just to further organize the appearance of channels in the settings UI + private static final String CH_GRP_MSG = "chgrp_msg"; + + // this is to group together notifications as such, maybe including a summary, + // see https://developer.android.com/training/notify-user/group.html + private static final String GRP_MSG = "grp_msg"; + + + // Notification IDs + // -------------------------------------------------------------------------------------------- + + public static final int ID_PERMANENT = 1; + public static final int ID_MSG_SUMMARY = 2; + public static final int ID_GENERIC = 3; + public static final int ID_FETCH = 4; + public static final int ID_MSG_OFFSET = 0; // msgId is added - as msgId start at 10, there are no conflicts with lower numbers + + + // Notification channels + // -------------------------------------------------------------------------------------------- + + // Overview: + // - since SDK 26 (Oreo), a NotificationChannel is a MUST for notifications + // - NotificationChannels are defined by a channelId + // and its user-editable settings have a higher precedence as the Notification.Builder setting + // - once created, NotificationChannels cannot be modified programmatically + // - NotificationChannels can be deleted, however, on re-creation with the same id, + // it becomes un-deleted with the old user-defined settings + // + // How we use Notification channel: + // - We include the delta-chat-notifications settings into the name of the channelId + // - The chatId is included only, if there are separate sound- or vibration-settings for a chat + // - This way, we have stable and few channelIds and the user + // can edit the notifications in ArcaneChat as well as in the system + + // channelIds: CH_MSG_* are used here, the other ones from outside (defined here to have some overview) + public static final String CH_MSG_PREFIX = "ch_msg"; + public static final String CH_MSG_VERSION = "5"; + public static final String CH_PERMANENT = "dc_fg_notification_ch"; + public static final String CH_GENERIC = "ch_generic"; + public static final String CH_CALLS_PREFIX = "call_chan"; + + private boolean notificationChannelsSupported() { + return Build.VERSION.SDK_INT >= 26; + } + + // full name is "ch_msgV_HASH" or "ch_msgV_HASH.ACCOUNTID.CHATID" + private String computeChannelId(String ledColor, boolean vibrate, @Nullable Uri ringtone, ChatData chatData) { + String channelId = CH_MSG_PREFIX; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(ledColor.getBytes()); + md.update(vibrate ? (byte) 1 : (byte) 0); + md.update((ringtone != null ? ringtone.toString() : "").getBytes()); + String hash = String.format("%X", new BigInteger(1, md.digest())).substring(0, 16); + + channelId = CH_MSG_PREFIX + CH_MSG_VERSION + "_" + hash; + if (chatData != null) { + channelId += String.format(".%d.%d", chatData.accountId, chatData.chatId); + } + + } catch(Exception e) { + Log.e(TAG, e.toString()); + } + return channelId; + } + + // return ChatData(ACCOUNTID, CHATID) from "ch_msgV_HASH.ACCOUNTID.CHATID" or null + private ChatData parseNotificationChannelChat(String channelId) { + try { + int point = channelId.lastIndexOf("."); + if (point>0) { + int chatId = Integer.parseInt(channelId.substring(point + 1)); + channelId = channelId.substring(0, point); + point = channelId.lastIndexOf("."); + if (point>0) { + int accountId = Integer.parseInt(channelId.substring(point + 1)); + return new ChatData(accountId, chatId); + } + } + } catch(Exception ignored) { } + return null; + } + + private String getNotificationChannelGroup(NotificationManagerCompat notificationManager) { + if (notificationChannelsSupported() && notificationManager.getNotificationChannelGroup(CH_GRP_MSG) == null) { + NotificationChannelGroup chGrp = new NotificationChannelGroup(CH_GRP_MSG, context.getString(R.string.pref_chats)); + notificationManager.createNotificationChannelGroup(chGrp); + } + return CH_GRP_MSG; + } + + private String getNotificationChannel(NotificationManagerCompat notificationManager, ChatData chatData, DcChat dcChat) { + String channelId = CH_MSG_PREFIX; + + if (notificationChannelsSupported()) { + try { + // get all values we'll use as settings for the NotificationChannel + String ledColor = Prefs.getNotificationLedColor(context); + boolean defaultVibrate = effectiveVibrate(chatData); + @Nullable Uri ringtone = effectiveSound(chatData); + boolean isIndependent = requiresIndependentChannel(chatData); + + // get channel id from these settings + channelId = computeChannelId(ledColor, defaultVibrate, ringtone, isIndependent? chatData : null); + + // user-visible name of the channel - + // we just use the name of the chat or "Default" + // (the name is shown in the context of the group "Chats" - that should be enough context) + String name = context.getString(R.string.def); + if (isIndependent) { + name = dcChat.getName(); + } + + // check if there is already a channel with the given name + List channels = notificationManager.getNotificationChannels(); + boolean channelExists = false; + for (int i = 0; i < channels.size(); i++) { + String currChannelId = channels.get(i).getId(); + if (currChannelId.startsWith(CH_MSG_PREFIX)) { + // this is one of the message channels handled here ... + if (currChannelId.equals(channelId)) { + // ... this is the actually required channel, fine :) + // update the name to reflect localize changes and chat renames + channelExists = true; + channels.get(i).setName(name); + } else { + // ... another message channel, delete if it is not in use. + ChatData currChat = parseNotificationChannelChat(currChannelId); + if (!currChannelId.equals(computeChannelId(ledColor, effectiveVibrate(currChat), effectiveSound(currChat), currChat))) { + notificationManager.deleteNotificationChannel(currChannelId); + } + } + } + } + + // create a channel with the given settings; + // we cannot change the settings, however, this is handled by using different values for chId + if(!channelExists) { + NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_HIGH); + channel.setDescription("Informs about new messages."); + channel.setGroup(getNotificationChannelGroup(notificationManager)); + channel.enableVibration(defaultVibrate); + channel.setShowBadge(true); + + if (!ledColor.equals("none")) { + channel.enableLights(true); + channel.setLightColor(getLedArgb(ledColor)); + } else { + channel.enableLights(false); + } + + if (ringtone != null && !TextUtils.isEmpty(ringtone.toString())) { + channel.setSound(ringtone, + new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build()); + } else { + channel.setSound(null, null); + } + + notificationManager.createNotificationChannel(channel); + } + } + catch(Exception e) { + Log.e(TAG, "Error in getNotificationChannel()", e); + } + } + + return channelId; + } + + public String getCallNotificationChannel(NotificationManagerCompat notificationManager, ChatData chatData, String name) { + String channelId = CH_CALLS_PREFIX + "-" + chatData.accountId + "-"+ chatData.chatId; + + if (notificationChannelsSupported()) { + try { + name = "(calls) " + name; + + // check if there is already a channel with the given name + List channels = notificationManager.getNotificationChannels(); + boolean channelExists = false; + for (int i = 0; i < channels.size(); i++) { + String currChannelId = channels.get(i).getId(); + if (currChannelId.startsWith(CH_CALLS_PREFIX)) { + // this is one of the calls channels handled here ... + if (currChannelId.equals(channelId)) { + // ... this is the actually required channel, fine :) + // update the name to reflect localize changes and chat renames + channelExists = true; + channels.get(i).setName(name); + } + } + } + + // create a the channel + if(!channelExists) { + NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_MAX); + channel.setDescription("Informs about incoming calls."); + channel.setShowBadge(true); + + Uri ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + channel.setSound(ringtone, new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()); + notificationManager.createNotificationChannel(channel); + } + } catch(Exception e) { + Log.e(TAG, "Error in getCallNotificationChannel()", e); + } + } + + return channelId; + } + + + // add notifications & co. + // -------------------------------------------------------------------------------------------- + + public void notifyCall(int accId, int callId, String payload) { + Util.runOnAnyBackgroundThread(() -> { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + DcContext dcContext = context.dcAccounts.getAccount(accId); + int chatId = dcContext.getMsg(callId).getChatId(); + DcChat dcChat = dcContext.getChat(chatId); + String name = dcChat.getName(); + ChatData chatData = new ChatData(accId, chatId); + String notificationChannel = getCallNotificationChannel(notificationManager, chatData, name); + + PendingIntent declineCallIntent = getDeclineCallIntent(chatData, callId); + PendingIntent openCallIntent = getOpenCallIntent(chatData, callId, payload, false); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationChannel) + .setSmallIcon(R.drawable.icon_notification) + .setColor(context.getResources().getColor(R.color.def_accent)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setOngoing(true) + .setOnlyAlertOnce(false) + .setTicker(name) + .setContentTitle(name) + .setFullScreenIntent(openCallIntent, true) + .setContentIntent(openCallIntent) + .setContentText("Incoming Call"); + + builder.addAction( + new NotificationCompat.Action.Builder( + R.drawable.baseline_call_end_24, + context.getString(R.string.end_call), + declineCallIntent).build()); + + builder.addAction( + new NotificationCompat.Action.Builder( + R.drawable.ic_videocam_white_24dp, + context.getString(R.string.answer_call), + getOpenCallIntent(chatData, callId, payload, true)).build()); + + Bitmap bitmap = getAvatar(dcChat); + if (bitmap != null) { + builder.setLargeIcon(bitmap); + } + + Notification notif = builder.build(); + notif.flags = notif.flags | Notification.FLAG_INSISTENT; + try { + notificationManager.notify("call-" + accId, callId, notif); + } catch (Exception e) { + Log.e(TAG, "cannot add notification", e); + } + }); + } + + public void notifyMessage(int accountId, int chatId, int msgId) { + Util.runOnAnyBackgroundThread(() -> { + DcContext dcContext = context.dcAccounts.getAccount(accountId); + DcChat dcChat = dcContext.getChat(chatId); + + DcMsg dcMsg = dcContext.getMsg(msgId); + NotificationPrivacyPreference privacy = Prefs.getNotificationPrivacy(context); + + String shortLine = privacy.isDisplayMessage()? dcMsg.getSummarytext(2000) : context.getString(R.string.notify_new_message); + if (dcChat.isMultiUser() && privacy.isDisplayContact()) { + shortLine = dcMsg.getSenderName(dcContext.getContact(dcMsg.getFromId())) + ": " + shortLine; + } + String tickerLine = shortLine; + if (!dcChat.isMultiUser() && privacy.isDisplayContact()) { + tickerLine = dcMsg.getSenderName(dcContext.getContact(dcMsg.getFromId())) + ": " + tickerLine; + + if (dcMsg.getOverrideSenderName() != null) { + // There is an "overridden" display name on the message, so, we need to prepend the display name to the message, + // i.e. set the shortLine to be the same as the tickerLine. + shortLine = tickerLine; + } + } + + DcMsg quotedMsg = dcMsg.getQuotedMsg(); + boolean isMention = dcChat.isMultiUser() && quotedMsg != null && quotedMsg.isOutgoing(); + + maybeAddNotification(accountId, dcChat, msgId, shortLine, tickerLine, true, isMention); + }); + } + + public void notifyReaction(int accountId, int contactId, int msgId, String reaction) { + Util.runOnAnyBackgroundThread(() -> { + DcContext dcContext = context.dcAccounts.getAccount(accountId); + DcMsg dcMsg = dcContext.getMsg(msgId); + + NotificationPrivacyPreference privacy = Prefs.getNotificationPrivacy(context); + if (!privacy.isDisplayContact() || !privacy.isDisplayMessage()) { + return; // showing "New Message" is wrong and showing "New Reaction" is already content. just do nothing. + } + + DcContact sender = dcContext.getContact(contactId); + String shortLine = context.getString(R.string.reaction_by_other, sender.getDisplayName(), reaction, dcMsg.getSummarytext(2000)); + DcChat dcChat = dcContext.getChat(dcMsg.getChatId()); + maybeAddNotification(accountId, dcChat, msgId, shortLine, shortLine, false, dcChat.isMultiUser()); + }); + } + + public void notifyWebxdc(int accountId, int contactId, int msgId, String text) { + Util.runOnAnyBackgroundThread(() -> { + NotificationPrivacyPreference privacy = Prefs.getNotificationPrivacy(context); + if (!privacy.isDisplayContact() || !privacy.isDisplayMessage()) { + return; // showing "New Message" is wrong, just do nothing. + } + + DcContext dcContext = context.dcAccounts.getAccount(accountId); + DcMsg dcMsg = dcContext.getMsg(msgId); + DcMsg parentMsg; + if(dcMsg.getType() == DcMsg.DC_MSG_WEBXDC) { + parentMsg = dcMsg; + } else { // info message, get parent xdc + parentMsg = dcMsg.getParent() != null? dcMsg.getParent() : dcMsg; + } + + if (Util.equals(visibleWebxdc, new Pair<>(accountId, parentMsg.getId()))) { + return; // do not notify if the app is already open + } + + JSONObject info = parentMsg.getWebxdcInfo(); + final String name = JsonUtils.optString(info, "name"); + String shortLine = name.isEmpty()? text : (name + ": " + text); + DcChat dcChat = dcContext.getChat(dcMsg.getChatId()); + maybeAddNotification(accountId, dcChat, msgId, shortLine, shortLine, false, dcChat.isMultiUser()); + }); + } + + @WorkerThread + private void maybeAddNotification(int accountId, DcChat dcChat, int msgId, String shortLine, String tickerLine, boolean playInChatSound, boolean isMention) { + + DcContext dcContext = context.dcAccounts.getAccount(accountId); + int chatId = dcChat.getId(); + ChatData chatData = new ChatData(accountId, chatId); + isMention = isMention && dcContext.isMentionsEnabled(); + + if (dcContext.isMuted() || (!isMention && dcChat.isMuted())) { + return; + } + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !notificationManager.areNotificationsEnabled()) { + return; + } + + if (Util.equals(visibleChat, chatData)) { + if (playInChatSound && Prefs.isInChatNotifications(context)) { + InChatSounds.getInstance(context).playIncomingSound(); + } + return; + } + + NotificationPrivacyPreference privacy = Prefs.getNotificationPrivacy(context); + long now = System.currentTimeMillis(); + boolean signal = (now - lastAudibleNotification) > MIN_AUDIBLE_PERIOD_MILLIS; + if (signal) { + lastAudibleNotification = now; + } + + // create a basic notification + // even without a name or message displayed, + // it makes sense to use separate notification channels and to open the respective chat directly - + // the user may eg. have chosen a different sound + String notificationChannel = getNotificationChannel(notificationManager, chatData, dcChat); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationChannel) + .setSmallIcon(R.drawable.icon_notification) + .setColor(context.getResources().getColor(R.color.def_accent)) + .setPriority(Prefs.getNotificationPriority(context)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setOnlyAlertOnce(!signal) + .setContentText(shortLine) + .setDeleteIntent(getMarkAsReadIntent(chatData, msgId, false)) + .setContentIntent(getOpenChatIntent(chatData)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setGroup(GRP_MSG + "." + accountId); + } + + String accountTag = dcContext.getConfig(CONFIG_PRIVATE_TAG); + if (accountTag.isEmpty() && context.dcAccounts.getAll().length > 1) { + accountTag = dcContext.getName(); + } + + if (privacy.isDisplayContact()) { + builder.setContentTitle(dcChat.getName()); + if (!TextUtils.isEmpty(accountTag)) { + builder.setSubText(accountTag); + } + } + + builder.setTicker(tickerLine); + + // set sound, vibrate, led for systems that do not have notification channels + if (!notificationChannelsSupported()) { + if (signal) { + Uri sound = effectiveSound(chatData); + if (sound != null && !TextUtils.isEmpty(sound.toString())) { + builder.setSound(sound); + } + boolean vibrate = effectiveVibrate(chatData); + if (vibrate) { + builder.setDefaults(Notification.DEFAULT_VIBRATE); + } + } + String ledColor = Prefs.getNotificationLedColor(context); + if (!ledColor.equals("none")) { + builder.setLights(getLedArgb(ledColor),500, 2000); + } + } + + // set avatar + if (privacy.isDisplayContact()) { + Bitmap bitmap = getAvatar(dcChat); + if (bitmap != null) { + builder.setLargeIcon(bitmap); + } + } + + // add buttons that allow some actions without opening ArcaneChat. + // if privacy options are enabled, the buttons are not added. + if (privacy.isDisplayContact() && privacy.isDisplayMessage()) { + try { + PendingIntent inNotificationReplyIntent = getRemoteReplyIntent(chatData, msgId); + PendingIntent markReadIntent = getMarkAsReadIntent(chatData, msgId, true); + + NotificationCompat.Action markAsReadAction = new NotificationCompat.Action(R.drawable.check, + context.getString(R.string.mark_as_read_short), + markReadIntent); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(R.drawable.ic_reply_white_36dp, + context.getString(R.string.notify_reply_button), + inNotificationReplyIntent) + .addRemoteInput(new RemoteInput.Builder(RemoteReplyReceiver.EXTRA_REMOTE_REPLY) + .setLabel(context.getString(R.string.notify_reply_button)).build()) + .build(); + builder.addAction(replyAction); + } + + NotificationCompat.Action wearableReplyAction = new NotificationCompat.Action.Builder(R.drawable.ic_reply, + context.getString(R.string.notify_reply_button), + inNotificationReplyIntent) + .addRemoteInput(new RemoteInput.Builder(RemoteReplyReceiver.EXTRA_REMOTE_REPLY) + .setLabel(context.getString(R.string.notify_reply_button)).build()) + .build(); + builder.addAction(markAsReadAction); + builder.extend(new NotificationCompat.WearableExtender().addAction(markAsReadAction).addAction(wearableReplyAction)); + } catch(Exception e) { Log.w(TAG, e); } + } + + // create a tiny inbox (gets visible if the notification is expanded) + if (privacy.isDisplayContact() && privacy.isDisplayMessage()) { + try { + NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + synchronized (inboxes) { + HashMap> accountInbox = inboxes.get(accountId); + if (accountInbox == null) { + accountInbox = new HashMap<>(); + inboxes.put(accountId, accountInbox); + } + ArrayList lines = accountInbox.get(chatId); + if (lines == null) { + lines = new ArrayList<>(); + accountInbox.put(chatId, lines); + } + lines.add(shortLine); + + for (int l = 0; l < lines.size(); l++) { + inboxStyle.addLine(lines.get(l)); + } + } + builder.setStyle(inboxStyle); + } catch(Exception e) { Log.w(TAG, e); } + } + + // messages count, some os make some use of that + // - do not use setSubText() as this is displayed together with setContentInfo() eg. on Lollipop + // - setNumber() may overwrite setContentInfo(), should be called last + // weird stuff. + int cnt = dcContext.getFreshMsgCount(chatId); + builder.setContentInfo(String.valueOf(cnt)); + builder.setNumber(cnt); + + // add notification, we use one notification per chat, + // esp. older android are not that great at grouping + try { + notificationManager.notify(String.valueOf(accountId), ID_MSG_OFFSET + chatId, builder.build()); + } catch (Exception e) { + Log.e(TAG, "cannot add notification", e); + } + + // group notifications together in a summary, this is possible since SDK 24 (Android 7) + // https://developer.android.com/training/notify-user/group.html + // in theory, this won't be needed due to setGroup(), however, in practise, it is needed up to at least Android 10. + if (Build.VERSION.SDK_INT >= 24) { + try { + NotificationCompat.Builder summary = new NotificationCompat.Builder(context, notificationChannel) + .setGroup(GRP_MSG + "." + accountId) + .setGroupSummary(true) + .setSmallIcon(R.drawable.icon_notification) + .setColor(context.getResources().getColor(R.color.def_accent, null)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setContentTitle("ArcaneChat") // content title would only be used on SDK <24 + .setContentText("New messages") // content text would only be used on SDK <24 + .setContentIntent(getOpenChatlistIntent(accountId)); + if (privacy.isDisplayContact() && !TextUtils.isEmpty(accountTag)) { + summary.setSubText(accountTag); + } + notificationManager.notify(String.valueOf(accountId), ID_MSG_SUMMARY, summary.build()); + } catch (Exception e) { + Log.e(TAG, "cannot add notification summary", e); + } + } + } + + public Bitmap getAvatar(DcChat dcChat) { + Recipient recipient = new Recipient(context, dcChat); + try { + Drawable drawable; + ContactPhoto contactPhoto = recipient.getContactPhoto(context); + if (contactPhoto != null) { + drawable = GlideApp.with(context.getApplicationContext()) + .load(contactPhoto) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) + .get(); + + } else { + drawable = recipient.getFallbackContactPhoto().asDrawable(context, recipient.getFallbackAvatarColor()); + } + if (drawable != null) { + int wh = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); + return BitmapUtil.createFromDrawable(drawable, wh, wh); + } + } catch (Exception e) { Log.w(TAG, e); } + + return null; + } + + public void removeCallNotification(int accountId, int callId) { + try { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + String tag = "call-" + accountId; + notificationManager.cancel(tag, callId); + } catch (Exception e) { Log.w(TAG, e); } + } + + public void removeNotifications(int accountId, int chatId) { + boolean removeSummary; + synchronized (inboxes) { + HashMap> accountInbox = inboxes.get(accountId); + if (accountInbox == null) { + accountInbox = new HashMap<>(); + } + accountInbox.remove(chatId); + removeSummary = accountInbox.isEmpty(); + } + + // cancel notification independently of inboxes array, + // due to restarts, the app may have notification even when inboxes is empty. + try { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + String tag = String.valueOf(accountId); + notificationManager.cancel(tag, ID_MSG_OFFSET + chatId); + if (removeSummary) { + notificationManager.cancel(tag, ID_MSG_SUMMARY); + } + } catch (Exception e) { Log.w(TAG, e); } + } + + public void removeAllNotifications(int accountId) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + String tag = String.valueOf(accountId); + synchronized (inboxes) { + HashMap> accountInbox = inboxes.get(accountId); + notificationManager.cancel(tag, ID_MSG_SUMMARY); + if (accountInbox != null) { + for (Integer chatId : accountInbox.keySet()) { + notificationManager.cancel(tag, chatId); + } + accountInbox.clear(); + } + } + } + + public void updateVisibleChat(int accountId, int chatId) { + Util.runOnAnyBackgroundThread(() -> { + + if (accountId != 0 && chatId != 0) { + visibleChat = new ChatData(accountId, chatId); + removeNotifications(accountId, chatId); + } else { + visibleChat = null; + } + + }); + } + + public void clearVisibleChat() { + visibleChat = null; + } + + public void updateVisibleWebxdc(int accountId, int msgId) { + if (accountId != 0 && msgId != 0) { + visibleWebxdc = new Pair<>(accountId, msgId); + } else { + visibleWebxdc = null; + } + } + + public void clearVisibleWebxdc() { + visibleWebxdc = null; + } + + public void maybePlaySendSound(DcChat dcChat) { + if (Prefs.isInChatNotifications(context) && !dcChat.isMuted()) { + InChatSounds.getInstance(context).playSendSound(); + } + } + + public static class ChatData { + public final int accountId; + public final int chatId; + + public ChatData(int accountId, int chatId) { + this.accountId = accountId; + this.chatId = chatId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ChatData chatData = (ChatData) o; + return accountId == chatData.accountId && chatId == chatData.chatId; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..21c8de8b091e1ce837e800af03067e0f212236de --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.notifications; + +import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.core.app.RemoteInput; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + + +/** + * Get the response text from the Wearable Device and sends an message as a reply + */ +public class RemoteReplyReceiver extends BroadcastReceiver { + + public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); + public static final String REPLY_ACTION = "org.thoughtcrime.securesms.notifications.WEAR_REPLY"; + public static final String ACCOUNT_ID_EXTRA = "account_id"; + public static final String CHAT_ID_EXTRA = "chat_id"; + public static final String MSG_ID_EXTRA = "msg_id"; + public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; + + @SuppressLint("StaticFieldLeak") + @Override + public void onReceive(final Context context, Intent intent) { + if (!REPLY_ACTION.equals(intent.getAction())) return; + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + final int accountId = intent.getIntExtra(ACCOUNT_ID_EXTRA, 0); + final int chatId = intent.getIntExtra(CHAT_ID_EXTRA, DC_CHAT_NO_CHAT); + final int msgId = intent.getIntExtra(MSG_ID_EXTRA, 0); + + if (remoteInput == null || chatId == DC_CHAT_NO_CHAT || accountId == 0) return; + + final CharSequence responseText = remoteInput.getCharSequence(EXTRA_REMOTE_REPLY); + + if (responseText != null) { + Util.runOnAnyBackgroundThread(() -> { + DcContext dcContext = DcHelper.getAccounts(context).getAccount(accountId); + dcContext.marknoticedChat(chatId); + dcContext.markseenMsgs(new int[]{msgId}); + if (dcContext.getChat(chatId).isContactRequest()) { + dcContext.acceptChat(chatId); + } + + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + msg.setText(responseText.toString()); + dcContext.sendMsg(chatId, msg); + + DcHelper.getNotificationCenter(context).removeNotifications(accountId, chatId); + }); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java new file mode 100644 index 0000000000000000000000000000000000000000..42becce2baf288dbfad0854fe58cc397f3f15e7e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -0,0 +1,379 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.ViewGroup; +import android.view.WindowManager; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.lang.ref.WeakReference; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class Permissions { + + public static String[] galleryPermissions() { + // on modern androids, the gallery picker works without permissions, + // however, the "camera roll" still requires permissions. + // to get that dialog at a UX-wise good moment, we still always ask for permission when opening gallery. + // just-always-asking this also avoids the mess with handling various paths for various apis. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return new String[]{Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO}; + } else { + return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}; + } + } + + private static final Map OUTSTANDING = new LRUCache<>(2); + + public static PermissionsBuilder with(@NonNull Activity activity) { + return new PermissionsBuilder(new ActivityPermissionObject(activity)); + } + + public static PermissionsBuilder with(@NonNull Fragment fragment) { + return new PermissionsBuilder(new FragmentPermissionObject(fragment)); + } + + public static class PermissionsBuilder { + + private final PermissionObject permissionObject; + + private String[] requestedPermissions; + + private Runnable allGrantedListener; + + private Runnable anyDeniedListener; + private Runnable anyPermanentlyDeniedListener; + private Runnable anyResultListener; + + private @DrawableRes int[] rationalDialogHeader; + private String rationaleDialogMessage; + + private boolean ifNecesary; + + private boolean condition = true; + + private boolean alwaysGranted = false; + + PermissionsBuilder(PermissionObject permissionObject) { + this.permissionObject = permissionObject; + } + + public PermissionsBuilder request(String... requestedPermissions) { + this.requestedPermissions = requestedPermissions; + return this; + } + + public PermissionsBuilder ifNecessary() { + this.ifNecesary = true; + return this; + } + + public PermissionsBuilder ifNecessary(boolean condition) { + this.ifNecesary = true; + this.condition = condition; + return this; + } + + public PermissionsBuilder alwaysGrantOnSdk30() { + if (Build.VERSION.SDK_INT >= 30) { + alwaysGranted = true; + } + return this; + } + + public PermissionsBuilder alwaysGrantOnSdk33() { + if (Build.VERSION.SDK_INT >= 33) { + alwaysGranted = true; + } + return this; + } + + public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) { + this.rationalDialogHeader = headers; + this.rationaleDialogMessage = message; + return this; + } + + public PermissionsBuilder withPermanentDenialDialog(@NonNull String message) { + return onAnyPermanentlyDenied(new SettingsDialogListener(permissionObject.getContext(), message)); + } + + public PermissionsBuilder onAllGranted(Runnable allGrantedListener) { + this.allGrantedListener = allGrantedListener; + return this; + } + + public PermissionsBuilder onAnyDenied(Runnable anyDeniedListener) { + this.anyDeniedListener = anyDeniedListener; + return this; + } + + @SuppressWarnings("WeakerAccess") + public PermissionsBuilder onAnyPermanentlyDenied(Runnable anyPermanentlyDeniedListener) { + this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener; + return this; + } + + public PermissionsBuilder onAnyResult(Runnable anyResultListener) { + this.anyResultListener = anyResultListener; + return this; + } + + public void execute() { + if (alwaysGranted) { + allGrantedListener.run(); + return; + } + + PermissionsRequest request = new PermissionsRequest(allGrantedListener, anyDeniedListener, anyPermanentlyDeniedListener, anyResultListener); + + if (ifNecesary && (permissionObject.hasAll(requestedPermissions) || !condition)) { + executePreGrantedPermissionsRequest(request); + } else if (rationaleDialogMessage != null && rationalDialogHeader != null) { + executePermissionsRequestWithRationale(request); + } else { + executePermissionsRequest(request); + } + } + + private void executePreGrantedPermissionsRequest(PermissionsRequest request) { + int[] grantResults = new int[requestedPermissions.length]; + Arrays.fill(grantResults, PackageManager.PERMISSION_GRANTED); + + request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]); + } + + @SuppressWarnings("ConstantConditions") + private void executePermissionsRequestWithRationale(PermissionsRequest request) { + RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader) + .setPositiveButton(R.string.perm_continue, (dialog, which) -> executePermissionsRequest(request)) + .setNegativeButton(R.string.not_now, (dialog, which) -> executeNoPermissionsRequest(request)) + .show() + .getWindow() + .setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private void executePermissionsRequest(PermissionsRequest request) { + int requestCode = new SecureRandom().nextInt(65434) + 100; + + synchronized (OUTSTANDING) { + OUTSTANDING.put(requestCode, request); + } + + for (String permission : requestedPermissions) { + request.addMapping(permission, permissionObject.shouldShouldPermissionRationale(permission)); + } + + permissionObject.requestPermissions(requestCode, requestedPermissions); + } + + private void executeNoPermissionsRequest(PermissionsRequest request) { + for (String permission : requestedPermissions) { + request.addMapping(permission, true); + } + + String[] permissions = filterNotGranted(permissionObject.getContext(), requestedPermissions); + int[] grantResults = new int[permissions.length]; + Arrays.fill(grantResults, PackageManager.PERMISSION_DENIED); + boolean[] showDialog = new boolean[permissions.length]; + Arrays.fill(showDialog, true); + + request.onResult(permissions, grantResults, showDialog); + } + + } + + private static void requestPermissions(@NonNull Activity activity, int requestCode, String... permissions) { + ActivityCompat.requestPermissions(activity, filterNotGranted(activity, permissions), requestCode); + } + + private static void requestPermissions(@NonNull Fragment fragment, int requestCode, String... permissions) { + fragment.requestPermissions(filterNotGranted(fragment.getContext(), permissions), requestCode); + } + + private static String[] filterNotGranted(@NonNull Context context, String... permissions) { + List notGranted = new ArrayList<>(); + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + notGranted.add(permission); + } + } + return notGranted.toArray(new String[0]); + } + + public static boolean hasAny(@NonNull Context context, String... permissions) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true; + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) return true; + } + return false; + } + + public static boolean hasAll(@NonNull Context context, String... permissions) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true; + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) return false; + } + return true; + } + + public static void onRequestPermissionsResult(Fragment fragment, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + onRequestPermissionsResult(new FragmentPermissionObject(fragment), requestCode, permissions, grantResults); + } + + public static void onRequestPermissionsResult(Activity activity, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + onRequestPermissionsResult(new ActivityPermissionObject(activity), requestCode, permissions, grantResults); + } + + private static void onRequestPermissionsResult(@NonNull PermissionObject context, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + PermissionsRequest resultListener; + + synchronized (OUTSTANDING) { + resultListener = OUTSTANDING.remove(requestCode); + } + + if (resultListener == null) return; + + boolean[] shouldShowRationaleDialog = new boolean[permissions.length]; + + for (int i=0;i context; + private final String message; + + SettingsDialogListener(Context context, String message) { + this.message = message; + this.context = new WeakReference<>(context); + } + + @Override + public void run() { + Context context = this.context.get(); + + if (context != null) { + new AlertDialog.Builder(context) + .setTitle(R.string.perm_required_title) + .setMessage(message) + .setPositiveButton(R.string.perm_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java b/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..1bc24d9aa545751a5ddec169603a4c5154fa31f6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.content.pm.PackageManager; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class PermissionsRequest { + + private final Map PRE_REQUEST_MAPPING = new HashMap<>(); + + private final @Nullable Runnable allGrantedListener; + + private final @Nullable Runnable anyDeniedListener; + private final @Nullable Runnable anyPermanentlyDeniedListener; + private final @Nullable Runnable anyResultListener; + + PermissionsRequest(@Nullable Runnable allGrantedListener, + @Nullable Runnable anyDeniedListener, + @Nullable Runnable anyPermanentlyDeniedListener, + @Nullable Runnable anyResultListener) + { + this.allGrantedListener = allGrantedListener; + + this.anyDeniedListener = anyDeniedListener; + this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener; + this.anyResultListener = anyResultListener; + } + + void onResult(String[] permissions, int[] grantResults, boolean[] shouldShowRationaleDialog) { + List granted = new ArrayList<>(permissions.length); + List denied = new ArrayList<>(permissions.length); + List permanentlyDenied = new ArrayList<>(permissions.length); + + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + granted.add(permissions[i]); + } else { + boolean preRequestShouldShowRationaleDialog = PRE_REQUEST_MAPPING.get(permissions[i]); + + if (anyPermanentlyDeniedListener != null + && !preRequestShouldShowRationaleDialog + && !shouldShowRationaleDialog[i]) { + permanentlyDenied.add(permissions[i]); + } else { + denied.add(permissions[i]); + } + } + } + + if (allGrantedListener != null && !granted.isEmpty() && (denied.isEmpty() && permanentlyDenied.isEmpty())) { + allGrantedListener.run(); + } + + if (!denied.isEmpty()) { + if (anyDeniedListener != null) anyDeniedListener.run(); + } + + if (!permanentlyDenied.isEmpty()) { + if (anyPermanentlyDeniedListener != null) anyPermanentlyDeniedListener.run(); + } + + if (anyResultListener != null) { + anyResultListener.run(); + } + } + + void addMapping(String permission, boolean shouldShowRationaleDialog) { + PRE_REQUEST_MAPPING.put(permission, shouldShowRationaleDialog); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java b/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..287b0aa11ccf7290d81c0c082bba54238ff938c6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.content.Context; +import android.graphics.Color; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class RationaleDialog { + + public static AlertDialog.Builder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) { + View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null); + ViewGroup header = view.findViewById(R.id.header_container); + TextView text = view.findViewById(R.id.message); + + for (int i=0;i { + updateListSummary(preference, newValue); + dcContext.setConfigInt(CONFIG_SHOW_EMAILS, Util.objectToInt(newValue)); + return true; + }); + } + + multiDeviceCheckbox = (CheckBoxPreference) this.findPreference("pref_bcc_self"); + if (multiDeviceCheckbox != null) { + multiDeviceCheckbox.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + dcContext.setConfigInt(CONFIG_BCC_SELF, 1); + return true; + } else { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.pref_multidevice_change_warn) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + dcContext.setConfigInt(CONFIG_BCC_SELF, 0); + ((CheckBoxPreference)preference).setChecked(false); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + return false; + } + }); + } + + mvboxMoveCheckbox = (CheckBoxPreference) this.findPreference("pref_mvbox_move"); + if (mvboxMoveCheckbox != null) { + mvboxMoveCheckbox.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + dcContext.setConfigInt(CONFIG_MVBOX_MOVE, enabled? 1 : 0); + return true; + }); + } + + onlyFetchMvboxCheckbox = this.findPreference("pref_only_fetch_mvbox"); + if (onlyFetchMvboxCheckbox != null) { + onlyFetchMvboxCheckbox.setOnPreferenceChangeListener(((preference, newValue) -> { + final boolean enabled = (Boolean) newValue; + if (enabled) { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.pref_imap_folder_warn_disable_defaults) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + dcContext.setConfigInt(CONFIG_ONLY_FETCH_MVBOX, 1); + ((CheckBoxPreference)preference).setChecked(true); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + return false; + } else { + dcContext.setConfigInt(CONFIG_ONLY_FETCH_MVBOX, 0); + return true; + } + })); + } + + webxdcRealtimeCheckbox = (CheckBoxPreference) this.findPreference("pref_webxdc_realtime_enabled"); + if (webxdcRealtimeCheckbox != null) { + webxdcRealtimeCheckbox.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + dcContext.setConfigInt(CONFIG_WEBXDC_REALTIME_ENABLED, enabled? 1 : 0); + return true; + }); + } + + Preference submitDebugLog = this.findPreference("pref_view_log"); + if (submitDebugLog != null) { + submitDebugLog.setOnPreferenceClickListener(new ViewLogListener()); + } + + Preference webxdcStore = this.findPreference(Prefs.WEBXDC_STORE_URL_PREF); + if (webxdcStore != null) { + webxdcStore.setOnPreferenceClickListener(new WebxdcStoreUrlListener()); + } + updateWebxdcStoreSummary(); + + Preference proxySettings = this.findPreference("proxy_settings_button"); + if (proxySettings != null) { + proxySettings.setOnPreferenceClickListener((preference) -> { + startActivity(new Intent(requireActivity(), ProxySettingsActivity.class)); + return true; + }); + } + + Preference relayListBtn = this.findPreference("pref_relay_list_button"); + if (relayListBtn != null) { + relayListBtn.setOnPreferenceClickListener(((preference) -> { + openRelayListActivity(); + return true; + })); + } + + if (dcContext.isChatmail()) { + findPreference("pref_category_legacy").setVisible(false); + } + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_advanced); + } + + @Override + public void onResume() { + super.onResume(); + Objects.requireNonNull(((ApplicationPreferencesActivity) requireActivity()).getSupportActionBar()).setTitle(R.string.menu_advanced); + + String value = Integer.toString(dcContext.getConfigInt("show_emails")); + showEmails.setValue(value); + updateListSummary(showEmails, value); + + multiDeviceCheckbox.setChecked(0!=dcContext.getConfigInt(CONFIG_BCC_SELF)); + mvboxMoveCheckbox.setChecked(0!=dcContext.getConfigInt(CONFIG_MVBOX_MOVE)); + onlyFetchMvboxCheckbox.setChecked(0!=dcContext.getConfigInt(CONFIG_ONLY_FETCH_MVBOX)); + webxdcRealtimeCheckbox.setChecked(0!=dcContext.getConfigInt(CONFIG_WEBXDC_REALTIME_ENABLED)); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_CONFIRM_CREDENTIALS_ACCOUNT) { + openRelayListActivity(); + } + } + + protected File copyToCacheDir(Uri uri) throws IOException { + try (InputStream inputStream = requireActivity().getContentResolver().openInputStream(uri)) { + File file = File.createTempFile("tmp-keys-file", ".tmp", requireActivity().getCacheDir()); + try (OutputStream outputStream = new FileOutputStream(file)) { + StreamUtil.copy(inputStream, outputStream); + } + return file; + } + } + + public static @NonNull String getVersion(@Nullable Context context) { + try { + if (context == null) return ""; + + String app = context.getString(R.string.app_name); + String version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; + + return String.format("%s %s", app, version); + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, e); + return context.getString(R.string.app_name); + } + } + + private class ViewLogListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + final Intent intent = new Intent(requireActivity(), LogViewActivity.class); + startActivity(intent); + return true; + } + } + + private class WebxdcStoreUrlListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + View gl = View.inflate(requireActivity(), R.layout.single_line_input, null); + EditText inputField = gl.findViewById(R.id.input_field); + inputField.setHint(Prefs.DEFAULT_WEBXDC_STORE_URL); + inputField.setText(Prefs.getWebxdcStoreUrl(requireActivity())); + inputField.setSelection(inputField.getText().length()); + inputField.setInputType(TYPE_TEXT_VARIATION_URI); + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.webxdc_store_url) + .setMessage(R.string.webxdc_store_url_explain) + .setView(gl) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dlg, btn) -> { + Prefs.setWebxdcStoreUrl(requireActivity(), inputField.getText().toString()); + updateWebxdcStoreSummary(); + }) + .show(); + return true; + } + } + + private void updateWebxdcStoreSummary() { + Preference preference = this.findPreference(Prefs.WEBXDC_STORE_URL_PREF); + if (preference != null) { + preference.setSummary(Prefs.getWebxdcStoreUrl(requireActivity())); + } + } + + private void openRelayListActivity() { + Intent intent = new Intent(requireActivity(), RelayListActivity.class); + startActivity(intent); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..e5dc4939c1653e9184cc79baaa09e5e4e5337ca8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Prefs; + +import java.util.Arrays; + +public class AppearancePreferenceFragment extends ListSummaryPreferenceFragment { + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + this.findPreference(Prefs.THEME_PREF).setOnPreferenceChangeListener(new ListSummaryListener()); + initializeListSummary((ListPreference)findPreference(Prefs.THEME_PREF)); + this.findPreference(Prefs.BACKGROUND_PREF).setOnPreferenceClickListener(new BackgroundClickListener()); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_appearance); + } + + @Override + public void onStart() { + super.onStart(); + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener((ApplicationPreferencesActivity)getActivity()); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.pref_appearance); + String imagePath = Prefs.getBackgroundImagePath(getContext(), dcContext.getAccountId()); + String backgroundString; + if(imagePath.isEmpty()){ + backgroundString = this.getString(R.string.def); + } + else{ + backgroundString = this.getString(R.string.custom); + } + this.findPreference(Prefs.BACKGROUND_PREF).setSummary(backgroundString); + } + + @Override + public void onStop() { + super.onStop(); + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener((ApplicationPreferencesActivity) getActivity()); + } + + public static CharSequence getSummary(Context context) { + String[] themeEntries = context.getResources().getStringArray(R.array.pref_theme_entries); + String[] themeEntryValues = context.getResources().getStringArray(R.array.pref_theme_values); + int themeIndex = Arrays.asList(themeEntryValues).indexOf(Prefs.getTheme(context)); + if (themeIndex == -1) themeIndex = 0; + + String imagePath = Prefs.getBackgroundImagePath(context, DcHelper.getContext(context).getAccountId()); + String backgroundString; + if(imagePath.isEmpty()){ + backgroundString = context.getString(R.string.def); + } + else{ + backgroundString = context.getString(R.string.custom); + } + + // adding combined strings as "Read receipt: %1$s, Screen lock: %1$s, " + // makes things inflexible on changes and/or adds lot of additional works to programmers. + // however, if needed, we can refine this later. + return themeEntries[themeIndex] + ", " + + context.getString(R.string.pref_background) + " " + backgroundString; + } + + private class BackgroundClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + Intent intent = new Intent(getContext(), ChatBackgroundActivity.class); + requireActivity().startActivity(intent); + return true; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/ChatBackgroundActivity.java b/src/main/java/org/thoughtcrime/securesms/preferences/ChatBackgroundActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..3723cfbad163d28fc8770759c7a79fbda1ef0e00 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/ChatBackgroundActivity.java @@ -0,0 +1,201 @@ +package org.thoughtcrime.securesms.preferences; + + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.view.Display; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.appcompat.app.ActionBar; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.concurrent.ExecutionException; + +public class ChatBackgroundActivity extends PassphraseRequiredActionBarActivity { + + Button galleryButton; + Button defaultButton; + ImageView preview; + + String tempDestinationPath; + Uri imageUri; + Boolean imageUpdate = false; + + private int accountId; + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.activity_select_chat_background); + + defaultButton = findViewById(R.id.set_default_button); + galleryButton = findViewById(R.id.from_gallery_button); + preview = findViewById(R.id.preview); + accountId = DcHelper.getContext(getApplicationContext()).getAccountId(); + + defaultButton.setOnClickListener(new DefaultClickListener()); + galleryButton.setOnClickListener(new GalleryClickListener()); + + String backgroundImagePath = Prefs.getBackgroundImagePath(this, accountId); + if(backgroundImagePath.isEmpty()){ + setDefaultLayoutBackgroundImage(); + }else { + setLayoutBackgroundImage(backgroundImagePath); + } + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.pref_background); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + inflater.inflate(R.menu.chat_background, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.apply_background) { + // handle confirmation button click here + Context context = getApplicationContext(); + if(imageUpdate) { + if (imageUri != null) { + Thread thread = new Thread() { + @Override + public void run() { + String destination = context.getFilesDir().getAbsolutePath() + "/background."+ accountId; + Prefs.setBackgroundImagePath(context, accountId, destination); + scaleAndSaveImage(context, destination); + } + }; + thread.start(); + } else { + Prefs.setBackgroundImagePath(context, accountId, ""); + } + } + finish(); + return true; + } else if (id == android.R.id.home) { + // handle close button click here + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void scaleAndSaveImage(Context context, String destinationPath) { + try{ + Display display = ServiceUtil.getWindowManager(context).getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + // resize so that the larger side fits the screen accurately + int largerSide = Math.max(size.x, size.y); + Bitmap scaledBitmap = GlideApp.with(context) + .asBitmap() + .load(imageUri) + .fitCenter() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit(largerSide, largerSide) + .get(); + FileOutputStream outStream = new FileOutputStream(destinationPath); + scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 85, outStream); + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + Prefs.setBackgroundImagePath(context, accountId, ""); + showBackgroundSaveError(); + } catch (FileNotFoundException e) { + e.printStackTrace(); + Prefs.setBackgroundImagePath(context, accountId, ""); + showBackgroundSaveError(); + } + } + + private void setLayoutBackgroundImage(String backgroundImagePath) { + Drawable image = Drawable.createFromPath(backgroundImagePath); + preview.setImageDrawable(image); + } + + private void setDefaultLayoutBackgroundImage() { + Drawable image = getResources().getDrawable(R.drawable.background_hd); + preview.setImageDrawable(image); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + final Context context = getApplicationContext(); + if (data != null && context != null && resultCode == RESULT_OK && requestCode == ApplicationPreferencesActivity.REQUEST_CODE_SET_BACKGROUND) { + imageUri = data.getData(); + if (imageUri != null) { + Thread thread = new Thread(){ + @Override + public void run() { + tempDestinationPath = context.getFilesDir().getAbsolutePath() + "/background-temp"; + scaleAndSaveImage(context, tempDestinationPath); + runOnUiThread(() -> { + // Stuff that updates the UI + setLayoutBackgroundImage(tempDestinationPath); + }); + } + }; + thread.start(); + } + imageUpdate=true; + } + } + + private void showBackgroundSaveError() { + Toast.makeText(this, R.string.error, Toast.LENGTH_LONG).show(); + } + + private class DefaultClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + imageUri = null; + tempDestinationPath = ""; + setDefaultLayoutBackgroundImage(); + imageUpdate=true; + } + + } + + private class GalleryClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + AttachmentManager.selectImage(ChatBackgroundActivity.this, ApplicationPreferencesActivity.REQUEST_CODE_SET_BACKGROUND); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..35fa083c7111bee52007777a681efd7d55de1624 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -0,0 +1,170 @@ +package org.thoughtcrime.securesms.preferences; + +import static android.app.Activity.RESULT_OK; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.CheckBoxPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.ScreenLockUtil; +import org.thoughtcrime.securesms.util.Util; + +public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { + private ListPreference mediaQuality; + private ListPreference autoDownload; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + mediaQuality = (ListPreference) this.findPreference("pref_compression"); + if (mediaQuality != null) { + mediaQuality.setOnPreferenceChangeListener((preference, newValue) -> { + updateListSummary(preference, newValue); + dcContext.setConfigInt(DcHelper.CONFIG_MEDIA_QUALITY, Util.objectToInt(newValue)); + return true; + }); + } + + + autoDownload = findPreference("auto_download"); + if (autoDownload != null) { + autoDownload.setOnPreferenceChangeListener((preference, newValue) -> { + updateListSummary(preference, newValue); + dcContext.setConfigInt("download_limit", Util.objectToInt(newValue)); + return true; + }); + } + nicerAutoDownloadNames(); + + Preference backup = this.findPreference("pref_backup"); + if (backup != null) { + backup.setOnPreferenceClickListener(new BackupListener()); + } + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_chats); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity)getActivity()).getSupportActionBar().setTitle(R.string.pref_chats_and_media); + + String value = Integer.toString(dcContext.getConfigInt(DcHelper.CONFIG_MEDIA_QUALITY)); + mediaQuality.setValue(value); + updateListSummary(mediaQuality, value); + + value = Integer.toString(dcContext.getConfigInt("download_limit")); + value = alignToMaxEntry(value, autoDownload.getEntryValues()); + autoDownload.setValue(value); + updateListSummary(autoDownload, value); + } + + // prefixes "Up to ..." to all entry names but the first one. + private void nicerAutoDownloadNames() { + CharSequence[] entries = autoDownload.getEntries(); + for (int i = 1 /*skip first*/; i < entries.length; i++) { + if (entries[i].equals("160 KiB")) { + entries[i] = getString(R.string.up_to_x_most_worse_quality_images, entries[i]); + } else if (entries[i].equals("640 KiB")) { + entries[i] = getString(R.string.up_to_x_most_balanced_quality_images, entries[i]); + } else { + entries[i] = getString(R.string.up_to_x, entries[i]); + } + } + autoDownload.setEntries(entries); + } + + // Assumes `entryValues` are sorted smallest (index 0) to largest (last index) + // and returns the an item close to `selectedValue`. + private String alignToMaxEntry(@NonNull String selectedValue, @NonNull CharSequence[] entryValues) { + try { + int selectedValueInt = Integer.parseInt(selectedValue); + for (int i = entryValues.length - 1; i >= 1 /*first is returned below*/; i--) { + int entryValueMin = i == 1 ? (Integer.parseInt(entryValues[i - 1].toString()) + 1) : Integer.parseInt(entryValues[i].toString()); + if (selectedValueInt >= entryValueMin) { + return entryValues[i].toString(); + } + } + return entryValues[0].toString(); + } catch(Exception e) { + return selectedValue; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_CONFIRM_CREDENTIALS_BACKUP) { + performBackup(); + } + } + + public static CharSequence getSummary(Context context) { + final String quality; + if (Prefs.isHardCompressionEnabled(context)) { + quality = context.getString(R.string.pref_outgoing_worse); + } else { + quality = context.getString(R.string.pref_outgoing_balanced); + } + return context.getString(R.string.pref_outgoing_media_quality) + " " + quality; + } + + /*********************************************************************************************** + * Backup + **********************************************************************************************/ + + private class BackupListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + boolean result = ScreenLockUtil.applyScreenLock(requireActivity(), getString(R.string.pref_backup), getString(R.string.enter_system_secret_to_continue), REQUEST_CODE_CONFIRM_CREDENTIALS_BACKUP); + if (!result) { + performBackup(); + } + return true; + } + } + + private void performBackup() { + Permissions.with(requireActivity()) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) // READ_EXTERNAL_STORAGE required to read folder contents and to generate backup names + .alwaysGrantOnSdk30() + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> { + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.pref_backup) + .setMessage(R.string.pref_backup_export_explain) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(R.string.pref_backup_export_this, (dialogInterface, i) -> startImexOne(DcContext.DC_IMEX_EXPORT_BACKUP)); + int[] allAccounts = DcHelper.getAccounts(requireActivity()).getAll(); + if (allAccounts.length > 1) { + String exportAllString = requireActivity().getString(R.string.pref_backup_export_all, allAccounts.length); + builder.setNegativeButton(exportAllString, (dialogInterface, i) -> startImexAll(DcContext.DC_IMEX_EXPORT_BACKUP)); + } + builder.show(); + }) + .execute(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..7c8796a7114e1910576baa501f108896d4dc7e5c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.preferences; + + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import org.thoughtcrime.securesms.components.CustomDefaultPreference; + +public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + View lv = getView().findViewById(android.R.id.list); + if (lv != null) lv.setPadding(0, 0, 0, 0); + } + + @Override + public void onDisplayPreferenceDialog(@NonNull Preference preference) { + DialogFragment dialogFragment = null; + + if (preference instanceof CustomDefaultPreference) { + dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); + } + + if (dialogFragment != null) { + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getFragmentManager(), "android.support.v7.preference.PreferenceFragment.DIALOG"); + } else { + super.onDisplayPreferenceDialog(preference); + } + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b6e541806fad98104a28e38b816825ad5e108b99 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java @@ -0,0 +1,222 @@ +package org.thoughtcrime.securesms.preferences; + + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.ScreenLockUtil; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public abstract class ListSummaryPreferenceFragment extends CorrectedPreferenceFragment implements DcEventCenter.DcEventDelegate { + protected static final int REQUEST_CODE_CONFIRM_CREDENTIALS_BACKUP = ScreenLockUtil.REQUEST_CODE_CONFIRM_CREDENTIALS + 1; + protected static final int REQUEST_CODE_CONFIRM_CREDENTIALS_KEYS = REQUEST_CODE_CONFIRM_CREDENTIALS_BACKUP + 1; + protected static final int REQUEST_CODE_CONFIRM_CREDENTIALS_ACCOUNT = REQUEST_CODE_CONFIRM_CREDENTIALS_KEYS + 1; + protected DcContext dcContext; + private NotificationController notificationController; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + dcContext = DcHelper.getContext(getContext()); + DcHelper.getEventCenter(getContext()).addObserver(DcContext.DC_EVENT_IMEX_PROGRESS, this); + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(getContext()).removeObservers(this); + + NotificationController notifController = notificationController; + if (notifController != null) { + // cancel backup when settings-activity is destroyed. + // + // where possible, we avoid the settings-activity from being destroyed, + // however, i did not find a simple way to cancel ConversationListActivity.onNewIntent() - + // which one is cleaning up "back stack" due to the singleTask flag. + // using a dummy activity and several workarounds all result even in worse side-effects + // than cancel-backup when the user relaunches the app. + // maybe we could bear the singleTask flag or could decouple + // backup completely from ui-flows - + // however, all this is some work and probably not maybe the effort just now. + // + // anyway, normally, the backup is fast enough and the users will just wait. + // btw, import does not have this issue (no singleTask in play there) + // and also for export, switching to other apps and tapping the notification will work. + // so, the current state is not that bad :) + notifController.close(); + notificationController = null; + stopOngoingProcess(); + Toast.makeText(getActivity(), R.string.export_aborted, Toast.LENGTH_LONG).show(); + } + + super.onDestroy(); + } + + protected class ListSummaryListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(@NonNull Preference preference, Object value) { + updateListSummary(preference, value); + return true; + } + } + + protected String getSelectedSummary(Preference preference, Object value) { + ListPreference listPref = (ListPreference) preference; + int entryIndex = Arrays.asList(listPref.getEntryValues()).indexOf(value); + return entryIndex >= 0 && entryIndex < listPref.getEntries().length + ? listPref.getEntries()[entryIndex].toString() + : getString(R.string.unknown); + } + + protected void updateListSummary(Preference preference, Object value) { + updateListSummary(preference, value, null); + } + + protected void updateListSummary(Preference preference, Object value, String hint) { + ListPreference listPref = (ListPreference) preference; + String summary = getSelectedSummary(preference, value); + if (hint != null) { + summary += "\n\n" + hint; + } + listPref.setSummary(summary); + } + + protected void initializeListSummary(ListPreference pref) { + pref.setSummary(pref.getEntry()); + } + + private Map imexProgress; + protected int[] imexAccounts; + protected int accountsDone; + + protected void startImexAll(int what) { + imexAccounts = DcHelper.getAccounts(getActivity()).getAll(); + imexProgress = new HashMap<>(); + accountsDone = 0; + showProgressDialog(); + String path = DcHelper.getImexDir().getAbsolutePath(); + for (int i = 0; i < imexAccounts.length; i++) { + startImexInner(imexAccounts[i], what, path, path); + } + } + + protected void startImexOne(int what) + { + String path = DcHelper.getImexDir().getAbsolutePath(); + startImexOne(what, path, path); + } + + protected void startImexOne(int what, String imexPath, String pathAsDisplayedToUser) { + imexAccounts = new int[]{ dcContext.getAccountId() }; + imexProgress = new HashMap<>(); + accountsDone = 0; + showProgressDialog(); + startImexInner(imexAccounts[0], what, imexPath, pathAsDisplayedToUser); + } + + protected ProgressDialog progressDialog = null; + protected int progressWhat = 0; + protected String pathAsDisplayedToUser = ""; + protected void startImexInner(int accountId, int what, String imexPath, String pathAsDisplayedToUser) + { + DcContext dcContext = DcHelper.getAccounts(getActivity()).getAccount(accountId); + this.pathAsDisplayedToUser = pathAsDisplayedToUser; + progressWhat = what; + dcContext.imex(progressWhat, imexPath); + } + + private void stopOngoingProcess() { + for (int accId : imexAccounts) { + DcHelper.getAccounts(requireActivity()).getAccount(accId).stopOngoingProcess(); + } + } + + private void showProgressDialog() { + notificationController = GenericForegroundService.startForegroundTask(getContext(), getString(R.string.export_backup_desktop)); + if( progressDialog!=null ) { + progressDialog.dismiss(); + progressDialog = null; + } + progressDialog = new ProgressDialog(getActivity()); + progressDialog.setMessage(getActivity().getString(R.string.one_moment)); + progressDialog.setCanceledOnTouchOutside(false); + progressDialog.setCancelable(false); + progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getActivity().getString(android.R.string.cancel), (dialog, which) -> { + notificationController.close(); + notificationController = null; + stopOngoingProcess(); + }); + progressDialog.show(); + } + + private int getTotalProgress() { + int progress = 0; + for (Integer accProgress : imexProgress.values()) { + progress += accProgress; + } + return progress; + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId()== DcContext.DC_EVENT_IMEX_PROGRESS) { + NotificationController notifController = notificationController; + if (notifController == null) return; + + long progress = event.getData1Int(); + Context context = getActivity(); + if (progress==0/*error/aborted*/) { + notifController.close(); + notificationController = null; + stopOngoingProcess(); + progressDialog.dismiss(); + progressDialog = null; + DcContext dcContext = DcHelper.getAccounts(context).getAccount(event.getAccountId()); + new AlertDialog.Builder(context) + .setMessage(dcContext.getLastError()) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + else if (progress<1000/*progress in permille*/) { + imexProgress.put(event.getAccountId(), (int) progress); + int totalProgress = getTotalProgress(); + int percent = totalProgress / (10 * imexAccounts.length); + String formattedPercent = percent > 0 ? String.format(" %d%%", percent) : ""; + progressDialog.setMessage(getResources().getString(R.string.one_moment) + formattedPercent); + notifController.setProgress(1000L * imexAccounts.length, totalProgress, formattedPercent); + } + else if (progress==1000/*done*/) { + accountsDone++; + if (accountsDone == imexAccounts.length) { + notifController.close(); + notificationController = null; + progressDialog.dismiss(); + progressDialog = null; + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.pref_backup_written_to_x, pathAsDisplayedToUser)) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + } + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..e6e282cd7e6815233abca5e3b86492c75c005ccf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -0,0 +1,263 @@ +package org.thoughtcrime.securesms.preferences; + +import static android.app.Activity.RESULT_OK; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.preference.CheckBoxPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.connect.KeepAliveService; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.util.Prefs; + +public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragment implements Preference.OnPreferenceChangeListener { + + private static final String TAG = NotificationsPreferenceFragment.class.getSimpleName(); + private static final int REQUEST_CODE_NOTIFICATION_SELECTED = 1; + + private CheckBoxPreference ignoreBattery; + private CheckBoxPreference notificationsEnabled; + private CheckBoxPreference mentionNotifEnabled; + private CheckBoxPreference reliableService; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + this.findPreference(Prefs.LED_COLOR_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(Prefs.RINGTONE_PREF) + .setOnPreferenceChangeListener(new RingtoneSummaryListener()); + this.findPreference(Prefs.NOTIFICATION_PRIVACY_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(Prefs.NOTIFICATION_PRIORITY_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + + this.findPreference(Prefs.RINGTONE_PREF) + .setOnPreferenceClickListener(preference -> { + Uri current = Prefs.getNotificationRingtone(getContext()); + if (current.toString().isEmpty()) current = null; // silent + + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + + startActivityForResult(intent, REQUEST_CODE_NOTIFICATION_SELECTED); + + return true; + }); + + initializeListSummary((ListPreference) findPreference(Prefs.LED_COLOR_PREF)); + initializeListSummary((ListPreference) findPreference(Prefs.NOTIFICATION_PRIVACY_PREF)); + initializeListSummary((ListPreference) findPreference(Prefs.NOTIFICATION_PRIORITY_PREF)); + + initializeRingtoneSummary(findPreference(Prefs.RINGTONE_PREF)); + + ignoreBattery = this.findPreference("pref_ignore_battery_optimizations"); + if (ignoreBattery != null) { + ignoreBattery.setVisible(needsIgnoreBatteryOptimizations()); + ignoreBattery.setOnPreferenceChangeListener((preference, newValue) -> { + requestToggleIgnoreBatteryOptimizations(); + return true; + }); + } + + + // reliableService is just used for displaying the actual value + // of the reliable service preference that is managed via + // Prefs.setReliableService() and Prefs.reliableService() + reliableService = this.findPreference("pref_reliable_service2"); + if (reliableService != null) { + reliableService.setOnPreferenceChangeListener(this); + } + + notificationsEnabled = this.findPreference("pref_enable_notifications"); + if (notificationsEnabled != null) { + notificationsEnabled.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + dcContext.setMuted(!enabled); + notificationsEnabled.setSummary(getSummary(getContext(), false)); + return true; + }); + } + + mentionNotifEnabled = this.findPreference("pref_enable_mention_notifications"); + if (mentionNotifEnabled != null) { + mentionNotifEnabled.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + dcContext.setMentionsEnabled(enabled); + return true; + }); + } + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_notifications); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.pref_notifications); + + // update ignoreBattery in onResume() to reflects changes done in the system settings + ignoreBattery.setChecked(isIgnoringBatteryOptimizations()); + notificationsEnabled.setChecked(!dcContext.isMuted()); + notificationsEnabled.setSummary(getSummary(getContext(), false)); + mentionNotifEnabled.setChecked(dcContext.isMentionsEnabled()); + + // set without altering "unset" state of the preference + reliableService.setOnPreferenceChangeListener(null); + reliableService.setChecked(Prefs.reliableService(getActivity())); + reliableService.setOnPreferenceChangeListener(this); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_NOTIFICATION_SELECTED && resultCode == RESULT_OK && data != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + if (Settings.System.DEFAULT_NOTIFICATION_URI.equals(uri)) { + Prefs.removeNotificationRingtone(getContext()); + } else { + Prefs.setNotificationRingtone(getContext(), uri != null ? uri : Uri.EMPTY); + } + + initializeRingtoneSummary(findPreference(Prefs.RINGTONE_PREF)); + } + } + + @Override + public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) { + Context context = getContext(); + boolean enabled = (Boolean) newValue; + Prefs.setReliableService(context, enabled); + if (enabled) { + KeepAliveService.startSelf(context); + } else { + context.stopService(new Intent(context, KeepAliveService.class)); + } + notificationsEnabled.setSummary(getSummary(context, false)); + return true; + } + + private class RingtoneSummaryListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) { + Uri value = (Uri) newValue; + + if (value == null || TextUtils.isEmpty(value.toString())) { + preference.setSummary(R.string.pref_silent); + } else { + Ringtone tone = RingtoneManager.getRingtone(getActivity(), value); + + if (tone != null) { + String summary; + try { + summary = tone.getTitle(getActivity()); + } catch (SecurityException e) { + // this could happen in some phones when user selects ringtone from + // external storage and later removes the read from external storage permission + // and later this method is called from initializeRingtoneSummary() + summary = ""; + Log.w(TAG, e); + } + preference.setSummary(summary); + } + } + + return true; + } + } + + private boolean needsIgnoreBatteryOptimizations() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + } + + private boolean isIgnoringBatteryOptimizations() { + if (!needsIgnoreBatteryOptimizations()) { + return true; + } + PowerManager pm = (PowerManager)getActivity().getSystemService(Context.POWER_SERVICE); + if(pm.isIgnoringBatteryOptimizations(getActivity().getPackageName())) { + return true; + } + return false; + } + + private void requestToggleIgnoreBatteryOptimizations() { + Context context = getActivity(); + boolean openManualSettings = true; + + try { + if (needsIgnoreBatteryOptimizations() + && !isIgnoringBatteryOptimizations() + && ContextCompat.checkSelfPermission(context, Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_GRANTED) { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + openManualSettings = false; + } + } catch (Exception e) { + e.printStackTrace(); + } + + if (openManualSettings && needsIgnoreBatteryOptimizations()) { + // fire ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS if ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS fails + // or if isIgnoringBatteryOptimizations() is already true (there is no intent to re-enable battery optimizations) + Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + context.startActivity(intent); + } + } + + private void initializeRingtoneSummary(Preference pref) { + RingtoneSummaryListener listener = (RingtoneSummaryListener) pref.getOnPreferenceChangeListener(); + Uri uri = Prefs.getNotificationRingtone(getContext()); + + listener.onPreferenceChange(pref, uri); + } + + public static CharSequence getSummary(Context context) { + return getSummary(context, true); + } + + public static CharSequence getSummary(Context context, boolean detailed) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || notificationManager.areNotificationsEnabled()) { + if (DcHelper.getContext(context).isMuted()) { + return detailed? context.getString(R.string.off) : ""; + } + if (FcmReceiveService.getToken() == null && !Prefs.reliableService(context)) { + return "⚠️ " + context.getString(R.string.unreliable_bg_notifications); + } + return detailed? context.getString(R.string.on) : ""; + } else { + return "⚠️ " + context.getString(R.string.disabled_in_system_settings); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/PrivacyPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/PrivacyPreferenceFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..023e882784a882ddf255e7718af0c9f749637211 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/PrivacyPreferenceFragment.java @@ -0,0 +1,179 @@ +package org.thoughtcrime.securesms.preferences; + +import static android.app.Activity.RESULT_OK; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_SHOW_EMAILS; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.CheckBoxPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.BlockedContactsActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +public class PrivacyPreferenceFragment extends ListSummaryPreferenceFragment { + private static final String TAG = PrivacyPreferenceFragment.class.getSimpleName(); + + private CheckBoxPreference readReceiptsCheckbox; + + private ListPreference autoDelDevice; + private ListPreference autoDelServer; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + readReceiptsCheckbox = (CheckBoxPreference) this.findPreference("pref_read_receipts"); + readReceiptsCheckbox.setOnPreferenceChangeListener(new ReadReceiptToggleListener()); + + this.findPreference("preference_category_blocked").setOnPreferenceClickListener(new BlockedContactsClickListener()); + + autoDelDevice = findPreference("autodel_device"); + autoDelDevice.setOnPreferenceChangeListener(new AutodelChangeListener("delete_device_after")); + + autoDelServer = findPreference("autodel_server"); + autoDelServer.setOnPreferenceChangeListener(new AutodelChangeListener("delete_server_after")); + if (dcContext.isChatmail()) { + autoDelServer.setVisible(false); + } + + Preference screenSecurity = this.findPreference(Prefs.SCREEN_SECURITY_PREF); + screenSecurity.setOnPreferenceChangeListener(new ScreenShotSecurityListener()); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_privacy); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity)getActivity()).getSupportActionBar().setTitle(R.string.pref_privacy); + + readReceiptsCheckbox.setChecked(0 != dcContext.getConfigInt("mdns_enabled")); + initAutodelFromCore(); + } + + private void initAutodelFromCore() { + String value = Integer.toString(dcContext.getConfigInt("delete_server_after")); + autoDelServer.setValue(value); + updateListSummary(autoDelServer, value, (value.equals("0") || dcContext.isChatmail())? null : getString(R.string.autodel_server_enabled_hint)); + + value = Integer.toString(dcContext.getConfigInt("delete_device_after")); + autoDelDevice.setValue(value); + updateListSummary(autoDelDevice, value); + } + + public static CharSequence getSummary(Context context) { + DcContext dcContext = DcHelper.getContext(context); + final String onRes = context.getString(R.string.on); + final String offRes = context.getString(R.string.off); + String readReceiptState = dcContext.getConfigInt("mdns_enabled")!=0? onRes : offRes; + return context.getString(R.string.pref_read_receipts) + " " + readReceiptState; + } + + private class BlockedContactsClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(getActivity(), BlockedContactsActivity.class); + startActivity(intent); + return true; + } + } + + private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean enabled = (boolean) newValue; + dcContext.setConfigInt("mdns_enabled", enabled ? 1 : 0); + return true; + } + } + + private class AutodelChangeListener implements Preference.OnPreferenceChangeListener { + private final String coreKey; + + AutodelChangeListener(String coreKey) { + this.coreKey = coreKey; + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + int timeout = Util.objectToInt(newValue); + Context context = preference.getContext(); + boolean fromServer = coreKey.equals("delete_server_after"); + if (timeout>0 && !(fromServer && dcContext.isChatmail())) { + int delCount = DcHelper.getContext(context).estimateDeletionCount(fromServer, timeout); + + View gl = View.inflate(getActivity(), R.layout.dialog_with_checkbox, null); + CheckBox confirmCheckbox = gl.findViewById(R.id.dialog_checkbox); + TextView msg = gl.findViewById(R.id.dialog_message); + + // If we'd use both `setMessage()` and `setView()` on the same AlertDialog, on small screens the + // "OK" and "Cancel" buttons would not be show. So, put the message into our custom view: + msg.setText(String.format(context.getString(fromServer? + R.string.autodel_server_ask : R.string.autodel_device_ask), + delCount, getSelectedSummary(preference, newValue))); + confirmCheckbox.setText(R.string.autodel_confirm); + + new AlertDialog.Builder(context) + .setTitle(preference.getTitle()) + .setView(gl) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + if (confirmCheckbox.isChecked()) { + dcContext.setConfigInt(coreKey, timeout); + initAutodelFromCore(); + } else { + onPreferenceChange(preference, newValue); + } + }) + .setNegativeButton(android.R.string.cancel, (dialog, whichButton) -> initAutodelFromCore()) + .setCancelable(true) // Enable the user to quickly cancel if they are intimidated by the warnings :) + .setOnCancelListener(dialog -> initAutodelFromCore()) + .show(); + } else if (fromServer && timeout == 1 /*at once, using a constant that cannot be used in .xml would weaken grep ability*/) { + new AlertDialog.Builder(context) + .setTitle(R.string.autodel_server_warn_multi_device_title) + .setMessage(R.string.autodel_server_warn_multi_device) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + dcContext.setConfigInt(coreKey, timeout); + initAutodelFromCore(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, whichButton) -> initAutodelFromCore()) + .setCancelable(true) + .setOnCancelListener(dialog -> initAutodelFromCore()) + .show(); + } else { + updateListSummary(preference, newValue); + dcContext.setConfigInt(coreKey, timeout); + } + return true; + } + } + + private class ScreenShotSecurityListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean enabled = (Boolean) newValue; + Prefs.setScreenSecurityEnabled(getContext(), enabled); + Toast.makeText(getContext(), R.string.pref_screen_security_please_restart_hint, Toast.LENGTH_LONG).show(); + return true; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java b/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java new file mode 100644 index 0000000000000000000000000000000000000000..251af9b93bf85eaf62869cdc16956ed27bb1605b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +public class NotificationPrivacyPreference { + + private final String preference; + + public NotificationPrivacyPreference(String preference) { + this.preference = preference; + } + + public boolean isDisplayContact() { + return "all".equals(preference) || "contact".equals(preference); + } + + public boolean isDisplayMessage() { + return "all".equals(preference); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java b/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java new file mode 100644 index 0000000000000000000000000000000000000000..307c79a76d23973e1d8b69f6c680e6095b08a01f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.MyProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Prefs; + +public class ProfilePreference extends Preference { + + private ImageView avatarView; + private TextView profileNameView; + private TextView profileStatusView; + + public ProfilePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public ProfilePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public ProfilePreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ProfilePreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setLayoutResource(R.layout.profile_preference_view); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder viewHolder) { + super.onBindViewHolder(viewHolder); + avatarView = (ImageView)viewHolder.findViewById(R.id.avatar); + profileNameView = (TextView)viewHolder.findViewById(R.id.profile_name); + profileStatusView = (TextView)viewHolder.findViewById(R.id.profile_status); + + refresh(); + } + + public void refresh() { + if (profileNameView == null) return; + + final String address = DcHelper.get(getContext(), DcHelper.CONFIG_CONFIGURED_ADDRESS); + final MyProfileContactPhoto profileImage = new MyProfileContactPhoto(address, String.valueOf(Prefs.getProfileAvatarId(getContext()))); + + GlideApp.with(getContext().getApplicationContext()) + .load(profileImage) + .error(new ResourceContactPhoto(R.drawable.ic_camera_alt_white_24dp).asDrawable(getContext(), getContext().getResources().getColor(R.color.grey_400))) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatarView); + + final String profileName = DcHelper.get(getContext(), DcHelper.CONFIG_DISPLAY_NAME); + if (!TextUtils.isEmpty(profileName)) { + profileNameView.setText(profileName); + } else { + profileNameView.setText(getContext().getString(R.string.pref_profile_info_headline)); + } + + final String status = DcHelper.get(getContext(), DcHelper.CONFIG_SELF_STATUS); + if (!TextUtils.isEmpty(status)) { + profileStatusView.setText(status); + } else { + profileStatusView.setText(getContext().getString(R.string.pref_default_status_label)); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..bf98af3d4dec65c81a6cdf22d8b8699fdd16c2bb --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.profiles; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.scribbles.ScribbleActivity; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +public class AvatarHelper { + /* the maximum width/height an avatar should have */ + public static final int AVATAR_SIZE = 640; + + public static void setGroupAvatar(Context context, int chatId, Bitmap bitmap) { + DcContext dcContext = DcHelper.getContext(context); + + if (bitmap == null) { + dcContext.setChatProfileImage(chatId, null); + } else { + try { + File avatar = File.createTempFile("groupavatar", ".jpg", context.getCacheDir()); + FileOutputStream out = new FileOutputStream(avatar); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out); + out.close(); + dcContext.setChatProfileImage(chatId, avatar.getPath()); // The avatar is copied to the blobs directory here... + //noinspection ResultOfMethodCallIgnored + avatar.delete(); // ..., now we can delete it. + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + public static File getSelfAvatarFile(@NonNull Context context) { + String dirString = DcHelper.getContext(context).getConfig(DcHelper.CONFIG_SELF_AVATAR); + return new File(dirString); + } + + public static void setSelfAvatar(@NonNull Context context, @Nullable Bitmap bitmap) throws IOException { + if (bitmap == null) { + DcHelper.set(context, DcHelper.CONFIG_SELF_AVATAR, null); + } else { + File avatar = File.createTempFile("selfavatar", ".jpg", context.getCacheDir()); + FileOutputStream out = new FileOutputStream(avatar); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out); + out.close(); + DcHelper.set(context, DcHelper.CONFIG_SELF_AVATAR, avatar.getPath()); // The avatar is copied to the blobs directory here... + //noinspection ResultOfMethodCallIgnored + avatar.delete(); // ..., now we can delete it. + } + } + + public static void cropAvatar(Activity context, Uri imageUri) { + Intent intent = new Intent(context, ScribbleActivity.class); + intent.setData(imageUri); + intent.putExtra(ScribbleActivity.CROP_AVATAR, true); + context.startActivityForResult(intent, ScribbleActivity.SCRIBBLE_REQUEST_CODE); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java b/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..0a85bc73e764dc1c6c73aca4312906ab33eb8805 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java @@ -0,0 +1,233 @@ +package org.thoughtcrime.securesms.providers; + +import android.annotation.SuppressLint; +import android.content.ContentUris; +import android.content.Context; +import android.content.UriMatcher; +import android.net.Uri; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.FileProviderUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PersistentBlobProvider { + + private static final String TAG = PersistentBlobProvider.class.getSimpleName(); + + private static final String URI_STRING = "content://org.thoughtcrime.securesms/capture-new"; + public static final Uri CONTENT_URI = Uri.parse(URI_STRING); + public static final String AUTHORITY = "org.thoughtcrime.securesms"; + public static final String EXPECTED_PATH_OLD = "capture/*/*/#"; + public static final String EXPECTED_PATH_NEW = "capture-new/*/*/*/*/#"; + + private static final int MIMETYPE_PATH_SEGMENT = 1; + public static final int FILENAME_PATH_SEGMENT = 2; + private static final int FILESIZE_PATH_SEGMENT = 3; + + private static final String BLOB_EXTENSION = "blob"; + private static final int MATCH_OLD = 1; + private static final int MATCH_NEW = 2; + + private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH) {{ + addURI(AUTHORITY, EXPECTED_PATH_OLD, MATCH_OLD); + addURI(AUTHORITY, EXPECTED_PATH_NEW, MATCH_NEW); + }}; + + private static volatile PersistentBlobProvider instance; + + public static PersistentBlobProvider getInstance() { + if (instance == null) { + synchronized (PersistentBlobProvider.class) { + if (instance == null) { + instance = new PersistentBlobProvider(); + } + } + } + return instance; + } + + @SuppressLint("UseSparseArrays") + private final ExecutorService executor = Executors.newCachedThreadPool(); + + private PersistentBlobProvider() { + } + + public Uri create(@NonNull Context context, + @NonNull byte[] blobBytes, + @NonNull String mimeType, + @Nullable String fileName) + { + final long id = System.currentTimeMillis(); + if (fileName == null) { + fileName = "file." + MediaUtil.getExtensionFromMimeType(mimeType); + } + return create(context, new ByteArrayInputStream(blobBytes), id, mimeType, fileName, (long) blobBytes.length); + } + + public Uri create(@NonNull Context context, + @NonNull InputStream input, + @NonNull String mimeType, + @Nullable String fileName, + @Nullable Long fileSize) + { + if (fileName == null) { + fileName = "file." + MediaUtil.getExtensionFromMimeType(mimeType); + } + return create(context, input, System.currentTimeMillis(), mimeType, fileName, fileSize); + } + + private Uri create(@NonNull Context context, + @NonNull InputStream input, + long id, + @NonNull String mimeType, + @Nullable String fileName, + @Nullable Long fileSize) + { + persistToDisk(context, id, input); + final Uri uniqueUri = CONTENT_URI.buildUpon() + .appendPath(mimeType) + .appendPath(fileName) + .appendEncodedPath(String.valueOf(fileSize)) + .appendEncodedPath(String.valueOf(System.currentTimeMillis())) + .build(); + return ContentUris.withAppendedId(uniqueUri, id); + } + + private void persistToDisk(@NonNull Context context, + final long id, final InputStream input) + { + executor.submit(() -> { + try { + OutputStream output = new FileOutputStream(getFile(context, id)); + Util.copy(input, output); + } catch (IOException e) { + Log.w(TAG, e); + } + }); + } + + public Uri createForExternal(@NonNull Context context, @NonNull String mimeType) throws IOException, IllegalStateException, NullPointerException { + File target = new File(getExternalDir(context), System.currentTimeMillis() + "." + getExtensionFromMimeType(mimeType)); + return FileProviderUtil.getUriFor(context, target); + } + + public boolean delete(@NonNull Context context, @NonNull Uri uri) { + switch (MATCHER.match(uri)) { + case MATCH_OLD: + case MATCH_NEW: + return getFile(context, ContentUris.parseId(uri)).delete(); + } + + //noinspection SimplifiableIfStatement + if (isExternalBlobUri(context, uri)) { + return new File(uri.getPath()).delete(); + } + + return false; + } + + public @NonNull InputStream getStream(@NonNull Context context, long id) throws IOException { + File file = getFile(context, id); + return new FileInputStream(file); + } + + private File getFile(@NonNull Context context, long id) { + File legacy = getLegacyFile(context, id); + File cache = getCacheFile(context, id); + File modernCache = getModernCacheFile(context, id); + + if (legacy.exists()) return legacy; + else if (cache.exists()) return cache; + else return modernCache; + } + + private File getLegacyFile(@NonNull Context context, long id) { + return new File(context.getDir("captures", Context.MODE_PRIVATE), id + "." + BLOB_EXTENSION); + } + + private File getCacheFile(@NonNull Context context, long id) { + return new File(context.getCacheDir(), "capture-" + id + "." + BLOB_EXTENSION); + } + + private File getModernCacheFile(@NonNull Context context, long id) { + return new File(context.getCacheDir(), "capture-m-" + id + "." + BLOB_EXTENSION); + } + + public static @Nullable String getMimeType(@NonNull Context context, @NonNull Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + return isExternalBlobUri(context, persistentBlobUri) + ? getMimeTypeFromExtension(persistentBlobUri) + : persistentBlobUri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); + } + + public static @Nullable String getFileName(@NonNull Context context, @NonNull Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + if (isExternalBlobUri(context, persistentBlobUri)) return null; + if (MATCHER.match(persistentBlobUri) == MATCH_OLD) return null; + + return persistentBlobUri.getPathSegments().get(FILENAME_PATH_SEGMENT); + } + + public static @Nullable Long getFileSize(@NonNull Context context, Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + if (isExternalBlobUri(context, persistentBlobUri)) return null; + if (MATCHER.match(persistentBlobUri) == MATCH_OLD) return null; + + try { + return Long.valueOf(persistentBlobUri.getPathSegments().get(FILESIZE_PATH_SEGMENT)); + } catch (NumberFormatException e) { + Log.w(TAG, e); + return null; + } + } + + private static @NonNull String getExtensionFromMimeType(String mimeType) { + final String extension = MediaUtil.getExtensionFromMimeType(mimeType); + return extension != null ? extension : BLOB_EXTENSION; + } + + private static @NonNull String getMimeTypeFromExtension(@NonNull Uri uri) { + final String mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(MediaUtil.getFileExtensionFromUrl(uri.toString())); + return mimeType != null ? mimeType : "application/octet-stream"; + } + + private static @NonNull File getExternalDir(Context context) throws IOException { + File externalDir = context.getExternalCacheDir(); + if (externalDir==null) { + externalDir = context.getCacheDir(); + } + if (externalDir == null) { + throw new IOException("no external files directory"); + } + return externalDir; + } + + public static boolean isAuthority(@NonNull Context context, @NonNull Uri uri) { + int matchResult = MATCHER.match(uri); + return matchResult == MATCH_NEW || matchResult == MATCH_OLD || isExternalBlobUri(context, uri); + } + + private static boolean isExternalBlobUri(@NonNull Context context, @NonNull Uri uri) { + try { + return uri.getPath().startsWith(getExternalDir(context).getAbsolutePath()); + } catch (IOException ioe) { + return false; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/providers/SingleUseBlobProvider.java b/src/main/java/org/thoughtcrime/securesms/providers/SingleUseBlobProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..3d395c398945047e0876bd8901d464d82b963c55 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/providers/SingleUseBlobProvider.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.providers; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +public class SingleUseBlobProvider { + + public static final String AUTHORITY = "org.thoughtcrime.securesms"; + public static final String PATH = "memory/*/#"; + + private final Map cache = new HashMap<>(); + + private static final SingleUseBlobProvider instance = new SingleUseBlobProvider(); + + public static SingleUseBlobProvider getInstance() { + return instance; + } + + private SingleUseBlobProvider() {} + + public synchronized @NonNull InputStream getStream(long id) throws IOException { + byte[] cached = cache.get(id); + cache.remove(id); + + if (cached != null) return new ByteArrayInputStream(cached); + else throw new IOException("ID not found: " + id); + + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/proxy/ProxyListAdapter.java b/src/main/java/org/thoughtcrime/securesms/proxy/ProxyListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..63217586bfd69e2c7441c6626eeb416ffd5b2ffb --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/proxy/ProxyListAdapter.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.proxy; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class ProxyListAdapter extends BaseAdapter { + private enum ProxyState { + CONNECTED, + CONNECTING, + NOT_CONNECTED, + } + + @NonNull private final Context context; + @NonNull private final DcContext dcContext; + @NonNull private final List proxies = new LinkedList<>(); + @Nullable private ItemClickListener itemClickListener; + @Nullable private ProxyState proxyState; + @Nullable private String selectedProxy; + + public ProxyListAdapter(@NonNull Context context) + { + this.context = context; + this.dcContext = DcHelper.getContext(context); + } + + @Override + public int getCount() { + return proxies.size(); + } + + @Override + public Object getItem(int position) { + return proxies.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(final int position, View v, final ViewGroup parent) { + if (v == null) { + v = LayoutInflater.from(context).inflate(R.layout.proxy_list_item, parent, false); + } + + TextView host = v.findViewById(R.id.host); + TextView protocol = v.findViewById(R.id.protocol); + ImageView checkmark = v.findViewById(R.id.checkmark); + TextView status = v.findViewById(R.id.status); + + final String proxyUrl = (String)getItem(position); + final DcLot qrParsed = dcContext.checkQr(proxyUrl); + if (qrParsed.getState() == DcContext.DC_QR_PROXY) { + host.setText(qrParsed.getText1()); + protocol.setText(proxyUrl.split(":", 2)[0]); + } else { + host.setText(proxyUrl); + protocol.setText(R.string.unknown); + } + if (proxyUrl.equals(selectedProxy)) { + checkmark.setVisibility(View.VISIBLE); + if(dcContext.isConfigured() == 1 && dcContext.getConfigInt(CONFIG_PROXY_ENABLED) == 1) { + status.setVisibility(View.VISIBLE); + status.setText(getConnectivityString()); + } else { + status.setVisibility(View.GONE); + } + } else { + checkmark.setVisibility(View.GONE); + status.setVisibility(View.GONE); + } + + v.setOnClickListener(view -> { + if (itemClickListener != null) { + itemClickListener.onItemClick(proxyUrl); + } + }); + v.findViewById(R.id.share).setOnClickListener(view -> { + if (itemClickListener != null) { + itemClickListener.onItemShare(proxyUrl); + } + }); + v.findViewById(R.id.delete).setOnClickListener(view -> { + if (itemClickListener != null) { + itemClickListener.onItemDelete(proxyUrl); + } + }); + + return v; + } + + public void changeData(String newProxies) { + proxies.clear(); + if (!TextUtils.isEmpty(newProxies)) { + Collections.addAll(proxies, newProxies.split("\n")); + } + selectedProxy = proxies.isEmpty()? null : proxies.get(0); + proxyState = null; // to force notifyDataSetChanged() in refreshConnectivity() + refreshConnectivity(); + } + + public void setSelectedProxy(String proxyUrl) { + selectedProxy = proxyUrl; + notifyDataSetChanged(); + } + + private String getConnectivityString() { + if (proxyState == ProxyState.CONNECTED) { + return context.getString(R.string.connectivity_connected); + } + if (proxyState == ProxyState.CONNECTING) { + return context.getString(R.string.connectivity_connecting); + } + return context.getString(R.string.connectivity_not_connected); + } + + public void refreshConnectivity() { + if (DcHelper.getInt(context, CONFIG_PROXY_ENABLED) != 1) { + if (proxyState != ProxyState.NOT_CONNECTED) { + proxyState = ProxyState.NOT_CONNECTED; + notifyDataSetChanged(); + } + return; + } + + int connectivity = dcContext.getConnectivity(); + if (connectivity >= DcContext.DC_CONNECTIVITY_WORKING) { + if (proxyState != ProxyState.CONNECTED) { + proxyState = ProxyState.CONNECTED; + notifyDataSetChanged(); + } + } else if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTING) { + if (proxyState != ProxyState.CONNECTING) { + proxyState = ProxyState.CONNECTING; + notifyDataSetChanged(); + } + } else if (proxyState != ProxyState.NOT_CONNECTED) { + proxyState = ProxyState.NOT_CONNECTED; + notifyDataSetChanged(); + } + } + + public void setItemClickListener(@Nullable ItemClickListener listener) { + itemClickListener = listener; + } + + public interface ItemClickListener { + void onItemClick(String proxyUrl); + void onItemShare(String proxyUrl); + void onItemDelete(String proxyUrl); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/proxy/ProxySettingsActivity.java b/src/main/java/org/thoughtcrime/securesms/proxy/ProxySettingsActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..7b917e94a962baa9258b82c703bab6ed9aae8bae --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/proxy/ProxySettingsActivity.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.proxy; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED; +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_URL; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcLot; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGImageView; +import com.caverock.androidsvg.SVGParseException; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.LinkedList; + +public class ProxySettingsActivity extends BaseActionBarActivity + implements ProxyListAdapter.ItemClickListener, DcEventCenter.DcEventDelegate { + + private SwitchCompat proxySwitch; + private ProxyListAdapter adapter; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.proxy_settings_activity); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.proxy_settings); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + proxySwitch = findViewById(R.id.proxy_switch); + ListView proxyList = findViewById(R.id.proxy_list); + + ViewUtil.applyWindowInsets(proxySwitch, true, false, true, false); + ViewUtil.applyWindowInsets(proxyList, true, false, true, true); + + adapter = new ProxyListAdapter(this); + adapter.setItemClickListener(this); + + proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1); + proxySwitch.setOnClickListener(l -> { + if (proxySwitch.isChecked() && adapter.getCount() == 0) { + showAddProxyDialog(); + } else { + DcHelper.set(this, CONFIG_PROXY_ENABLED, proxySwitch.isChecked()? "1" : "0"); + DcHelper.getContext(this).restartIo(); + } + }); + + proxyList.setAdapter(adapter); + proxyList.addHeaderView(View.inflate(this, R.layout.proxy_list_header, null), null, false); + View footer = View.inflate(this, R.layout.proxy_list_footer, null); + footer.setOnClickListener(l -> showAddProxyDialog()); + proxyList.addFooterView(footer); + adapter.changeData(DcHelper.get(this, CONFIG_PROXY_URL)); + DcHelper.getEventCenter(this).addObserver(DcContext.DC_EVENT_CONNECTIVITY_CHANGED, this); + + handleOpenProxyUrl(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + handleOpenProxyUrl(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + if (id == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onItemClick(String proxyUrl) { + if (DcHelper.getContext(this).setConfigFromQr(proxyUrl)) { + DcHelper.getContext(this).restartIo(); + adapter.setSelectedProxy(proxyUrl); + proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1); + } else { + Toast.makeText(this, R.string.proxy_invalid, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onItemShare(String proxyUrl) { + View view = View.inflate(this, R.layout.dialog_share_proxy, null); + SVGImageView qrImage = view.findViewById(R.id.qr_image); + try { + SVG svg = SVG.getFromString(DcHelper.getContext(this).createQrSvg(proxyUrl)); + qrImage.setSVG(svg); + } catch (SVGParseException e) { + e.printStackTrace(); + } + + AlertDialog dialog = new AlertDialog.Builder(this) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.proxy_share_link, (dlg, btn) -> { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, proxyUrl); + startActivity(Intent.createChooser(intent, getString(R.string.chat_share_with_title))); + }) + .show(); + } + + @Override + public void onItemDelete(String proxyUrl) { + String host = DcHelper.getContext(this).checkQr(proxyUrl).getText1(); + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.proxy_delete) + .setMessage(getString(R.string.proxy_delete_explain, host)) + .setPositiveButton(R.string.delete, (dlg, btn) -> deleteProxy(proxyUrl)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + Util.redPositiveButton(dialog); + } + + private void deleteProxy(String proxyUrl) { + final LinkedList proxies = new LinkedList<>(); + for (String proxy: DcHelper.get(this, CONFIG_PROXY_URL).split("\n")) { + if (!proxy.equals(proxyUrl)) { + proxies.add(proxy); + } + } + if (proxies.isEmpty()) { + DcHelper.set(this, CONFIG_PROXY_ENABLED, "0"); + proxySwitch.setChecked(false); + } + String proxyUrls = String.join("\n", proxies); + DcHelper.set(this, CONFIG_PROXY_URL, proxyUrls); + DcHelper.getContext(this).restartIo(); + adapter.changeData(proxyUrls); + } + + private void showAddProxyDialog() { + View view = View.inflate(this, R.layout.single_line_input, null); + EditText inputField = view.findViewById(R.id.input_field); + inputField.setHint(R.string.proxy_add_url_hint); + + new AlertDialog.Builder(this) + .setTitle(R.string.proxy_add) + .setMessage(R.string.proxy_add_explain) + .setView(view) + .setPositiveButton(R.string.proxy_use_proxy, (dialog, whichButton) -> { + String newProxy = inputField.getText().toString().trim(); + DcContext dcContext = DcHelper.getContext(this); + final DcLot qrParsed = dcContext.checkQr(newProxy); + if (qrParsed.getState() == DcContext.DC_QR_PROXY) { + dcContext.setConfigFromQr(newProxy); + DcHelper.getContext(this).restartIo(); + adapter.changeData(DcHelper.get(this, CONFIG_PROXY_URL)); + } else { + Toast.makeText(this, R.string.proxy_invalid, Toast.LENGTH_LONG).show(); + } + proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1); + }) + .setNegativeButton(android.R.string.cancel, (dialog, whichButton) -> { + if (proxySwitch.isChecked() && adapter.getCount() == 0) { + // user enabled switch without having proxies yet, revert + proxySwitch.setChecked(false); + } + }) + .setCancelable(false) + .show(); + } + + private void handleOpenProxyUrl() { + if (getIntent() != null && Intent.ACTION_VIEW.equals(getIntent().getAction())) { + Uri uri = getIntent().getData(); + if (uri == null) { + return; + } + + DcContext dcContext = DcHelper.getContext(this); + final DcLot qrParsed = dcContext.checkQr(uri.toString()); + if (qrParsed.getState() == DcContext.DC_QR_PROXY) { + new AlertDialog.Builder(this) + .setTitle(R.string.proxy_use_proxy) + .setMessage(getString(R.string.proxy_use_proxy_confirm, qrParsed.getText1())) + .setPositiveButton(R.string.proxy_use_proxy, (dlg, btn) -> { + dcContext.setConfigFromQr(uri.toString()); + dcContext.restartIo(); + adapter.changeData(DcHelper.get(this, CONFIG_PROXY_URL)); + proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1); + }) + .setNegativeButton(R.string.cancel, null) + .setCancelable(false) + .show(); + } else { + Toast.makeText(this, R.string.proxy_invalid, Toast.LENGTH_LONG).show(); + } + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_CONNECTIVITY_CHANGED) { + adapter.refreshConnectivity(); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/BackupProviderFragment.java b/src/main/java/org/thoughtcrime/securesms/qr/BackupProviderFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..3fc78a98eb6decaac2e09f2e9be37a39a7ed7cb8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/BackupProviderFragment.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.qr; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import com.b44t.messenger.DcBackupProvider; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGImageView; +import com.caverock.androidsvg.SVGParseException; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + +public class BackupProviderFragment extends Fragment implements DcEventCenter.DcEventDelegate { + + private final static String TAG = BackupProviderFragment.class.getSimpleName(); + + private DcContext dcContext; + private DcBackupProvider dcBackupProvider; + + private TextView statusLine; + private ProgressBar progressBar; + private View topText; + private SVGImageView qrImageView; + private boolean isFinishing; + private Thread prepareThread; + private Thread waitThread; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.backup_provider_fragment, container, false); + statusLine = view.findViewById(R.id.status_line); + progressBar = view.findViewById(R.id.progress_bar); + topText = view.findViewById(R.id.top_text); + qrImageView = view.findViewById(R.id.qrImage); + setHasOptionsMenu(true); + + statusLine.setText(R.string.preparing_account); + progressBar.setIndeterminate(true); + + dcContext = DcHelper.getContext(getActivity()); + DcHelper.getEventCenter(getActivity()).addObserver(DcContext.DC_EVENT_IMEX_PROGRESS, this); + + prepareThread = new Thread(() -> { + Log.i(TAG, "##### newBackupProvider()"); + dcBackupProvider = dcContext.newBackupProvider(); + Log.i(TAG, "##### newBackupProvider() returned"); + Util.runOnMain(() -> { + BackupTransferActivity activity = getTransferActivity(); + if (activity == null || activity.isFinishing() || isFinishing) { + return; + } + progressBar.setVisibility(View.GONE); + if (!dcBackupProvider.isOk()) { + activity.setTransferError("Cannot create backup provider"); + return; + } + statusLine.setVisibility(View.GONE); + topText.setVisibility(View.VISIBLE); + try { + SVG svg = SVG.getFromString(QrShowFragment.fixSVG(dcBackupProvider.getQrSvg())); + qrImageView.setSVG(svg); + qrImageView.setVisibility(View.VISIBLE); + } catch (SVGParseException e) { + e.printStackTrace(); + } + waitThread = new Thread(() -> { + Log.i(TAG, "##### waitForReceiver() with qr: "+dcBackupProvider.getQr()); + dcBackupProvider.waitForReceiver(); + Log.i(TAG, "##### done waiting"); + }); + waitThread.start(); + }); + }); + prepareThread.start(); + + BackupTransferActivity.appendSSID(getActivity(), view.findViewById(R.id.same_network_hint)); + + return view; + } + + @Override + public void onDestroyView() { + isFinishing = true; + DcHelper.getEventCenter(getActivity()).removeObservers(this); + dcContext.stopOngoingProcess(); + + try { + prepareThread.join(); + } catch (Exception e) { + e.printStackTrace(); + } + + // prepareThread has failed to create waitThread, as we already did wait for prepareThread right above. + // Order of waiting is important here. + if (waitThread!=null) { + try { + waitThread.join(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (dcBackupProvider != null) { + dcBackupProvider.unref(); + } + super.onDestroyView(); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.copy).setVisible(qrImageView.getVisibility() == View.VISIBLE); + super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + if (item.getItemId() == R.id.copy) { + if (dcBackupProvider != null) { + Util.writeTextToClipboard(getActivity(), dcBackupProvider.getQr()); + Toast.makeText(getActivity(), getString(R.string.done), Toast.LENGTH_SHORT).show(); + getTransferActivity().warnAboutCopiedQrCodeOnAbort = true; + } + return true; + } + + return false; + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_IMEX_PROGRESS) { + if (isFinishing) { + return; + } + + int permille = event.getData1Int(); + int percent = 0; + int percentMax = 0; + boolean hideQrCode = false; + String statusLineText = ""; + + Log.i(TAG,"DC_EVENT_IMEX_PROGRESS, " + permille); + if (permille == 0) { + getTransferActivity().setTransferError("Sending Error"); + hideQrCode = true; + } else if (permille < 1000) { + percent = permille/10; + percentMax = 100; + statusLineText = getString(R.string.transferring); + hideQrCode = true; + } else if (permille == 1000) { + statusLineText = getString(R.string.done) + " \uD83D\uDE00"; + getTransferActivity().setTransferState(BackupTransferActivity.TransferState.TRANSFER_SUCCESS); + progressBar.setVisibility(View.GONE); + hideQrCode = true; + } + + statusLine.setText(statusLineText); + getTransferActivity().notificationController.setProgress(percentMax, percent, statusLineText); + if (percentMax == 0) { + progressBar.setIndeterminate(true); + } else { + progressBar.setIndeterminate(false); + progressBar.setMax(percentMax); + progressBar.setProgress(percent); + } + + if (hideQrCode && qrImageView.getVisibility() != View.GONE) { + qrImageView.setVisibility(View.GONE); + topText.setVisibility(View.GONE); + statusLine.setVisibility(View.VISIBLE); + progressBar.setVisibility(permille == 1000 ? View.GONE : View.VISIBLE); + } + } + } + + private BackupTransferActivity getTransferActivity() { + return (BackupTransferActivity) getActivity(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/BackupReceiverFragment.java b/src/main/java/org/thoughtcrime/securesms/qr/BackupReceiverFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..9dc6596331a51f6ad1ff04fc9741b3d86f605e3e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/BackupReceiverFragment.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.qr; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + + +public class BackupReceiverFragment extends Fragment implements DcEventCenter.DcEventDelegate { + + private final static String TAG = BackupProviderFragment.class.getSimpleName(); + + private DcContext dcContext; + private TextView statusLine; + private ProgressBar progressBar; + private TextView sameNetworkHint; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.backup_receiver_fragment, container, false); + statusLine = view.findViewById(R.id.status_line); + progressBar = view.findViewById(R.id.progress_bar); + sameNetworkHint = view.findViewById(R.id.same_network_hint); + + statusLine.setText(R.string.connectivity_connecting); + progressBar.setIndeterminate(true); + + dcContext = DcHelper.getContext(getActivity()); + DcHelper.getEventCenter(getActivity()).addObserver(DcContext.DC_EVENT_IMEX_PROGRESS, this); + + String qrCode = getActivity().getIntent().getStringExtra(BackupTransferActivity.QR_CODE); + + new Thread(() -> { + Log.i(TAG, "##### receiveBackup() with qr: "+qrCode); + boolean res = dcContext.receiveBackup(qrCode); + Log.i(TAG, "##### receiveBackup() done with result: "+res); + }).start(); + + BackupTransferActivity.appendSSID(getActivity(), sameNetworkHint); + + return view; + } + + @Override + public void onDestroyView() { + dcContext.stopOngoingProcess(); + super.onDestroyView(); + DcHelper.getEventCenter(getActivity()).removeObservers(this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_IMEX_PROGRESS) { + int permille = event.getData1Int(); + int percent = 0; + int percentMax = 0; + boolean hideSameNetworkHint = false; + String statusLineText = ""; + + Log.i(TAG,"DC_EVENT_IMEX_PROGRESS, " + permille); + if (permille == 0) { + DcHelper.maybeShowMigrationError(getTransferActivity()); + getTransferActivity().setTransferError("Receiving Error"); + } else if (permille < 1000) { + percent = permille/10; + percentMax = 100; + String formattedPercent = percent > 0 ? String.format(Util.getLocale(), " %d%%", percent) : ""; + statusLineText = getString(R.string.transferring) + formattedPercent; + hideSameNetworkHint = true; + } else if (permille == 1000) { + getTransferActivity().setTransferState(BackupTransferActivity.TransferState.TRANSFER_SUCCESS); + getTransferActivity().doFinish(); + return; + } + + statusLine.setText(statusLineText); + getTransferActivity().notificationController.setProgress(percentMax, percent, statusLineText); + if (percentMax == 0) { + progressBar.setIndeterminate(true); + } else { + progressBar.setIndeterminate(false); + progressBar.setMax(percentMax); + progressBar.setProgress(percent); + } + + if (hideSameNetworkHint && sameNetworkHint.getVisibility() != View.GONE) { + sameNetworkHint.setVisibility(View.GONE); + } + } + } + + private BackupTransferActivity getTransferActivity() { + return (BackupTransferActivity) getActivity(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/BackupTransferActivity.java b/src/main/java/org/thoughtcrime/securesms/qr/BackupTransferActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..464d5d7eb6af3f77092685e8bec9e0a83daf081b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/BackupTransferActivity.java @@ -0,0 +1,229 @@ +package org.thoughtcrime.securesms.qr; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.LogViewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class BackupTransferActivity extends BaseActionBarActivity { + + private final static String TAG = BackupTransferActivity.class.getSimpleName(); + + public enum TransferMode { + INVALID(0), + SENDER_SHOW_QR(1), + RECEIVER_SCAN_QR(2); + private final int i; + TransferMode(int i) { this.i = i; } + public int getInt() { return i; } + public static TransferMode fromInt(int i) { return values()[i]; } + }; + + public enum TransferState { + TRANSFER_UNKNOWN, + TRANSFER_ERROR, + TRANSFER_SUCCESS; + }; + + public static final String TRANSFER_MODE = "transfer_mode"; + public static final String QR_CODE = "qr_code"; + + private TransferMode transferMode = TransferMode.RECEIVER_SCAN_QR; + private TransferState transferState = TransferState.TRANSFER_UNKNOWN; + + NotificationController notificationController; + private boolean notificationControllerClosed = false; + public boolean warnAboutCopiedQrCodeOnAbort = false; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + transferMode = TransferMode.fromInt(getIntent().getIntExtra(TRANSFER_MODE, TransferMode.INVALID.getInt())); + if (transferMode == TransferMode.INVALID) { + throw new RuntimeException("bad transfer mode"); + } + + DcHelper.getAccounts(this).stopIo(); + + String title = getString(transferMode == TransferMode.RECEIVER_SCAN_QR ? R.string.multidevice_receiver_title : R.string.multidevice_title); + notificationController = GenericForegroundService.startForegroundTask(this, title); + + setContentView(R.layout.backup_provider_activity); + + switch(transferMode) { + case SENDER_SHOW_QR: + initFragment(R.id.backup_provider_fragment, new BackupProviderFragment(), icicle); + break; + + case RECEIVER_SCAN_QR: + initFragment(R.id.backup_provider_fragment, new BackupReceiverFragment(), icicle); + break; + } + + ActionBar supportActionBar = getSupportActionBar(); + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + supportActionBar.setTitle(title); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.backup_provider_fragment)); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!notificationControllerClosed) { + notificationController.close(); + } + DcHelper.getAccounts(this).startIo(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + getMenuInflater().inflate(R.menu.backup_transfer_menu, menu); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public void onBackPressed() { + finishOrAskToFinish(); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finishOrAskToFinish(); + return true; + } else if (itemId == R.id.troubleshooting) { + DcHelper.openHelp(this, "#multiclient"); + return true; + } else if (itemId == R.id.view_log_button) { + startActivity(new Intent(this, LogViewActivity.class)); + return true; + } + + return false; + } + + public void setTransferState(TransferState transferState) { + this.transferState = transferState; + } + + public void setTransferError(@NonNull String errorContext) { + if (this.transferState != TransferState.TRANSFER_ERROR) { + this.transferState = TransferState.TRANSFER_ERROR; + + String lastError = DcHelper.getContext(this).getLastError(); + if (lastError.isEmpty()) { + lastError = ""; + } + + String error = errorContext; + if (!error.isEmpty()) { + error += ": "; + } + error += lastError; + + new AlertDialog.Builder(this) + .setMessage(error) + .setPositiveButton(android.R.string.ok, null) + .setCancelable(false) + .show(); + } + } + + private void finishOrAskToFinish() { + switch (transferState) { + case TRANSFER_ERROR: + case TRANSFER_SUCCESS: + doFinish(); + break; + + default: + String msg = getString(R.string.multidevice_abort); + if (warnAboutCopiedQrCodeOnAbort) { + msg += "\n\n" + getString(R.string.multidevice_abort_will_invalidate_copied_qr); + } + new AlertDialog.Builder(this) + .setMessage(msg) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> doFinish()) + .setNegativeButton(R.string.cancel, null) + .show(); + break; + } + } + + public void doFinish() { + // the permanent notification will prevent other activities to be started and kill BackupTransferActivity; + // close it before starting other activities + notificationController.close(); + notificationControllerClosed = true; + + if (transferMode == TransferMode.RECEIVER_SCAN_QR && transferState == TransferState.TRANSFER_SUCCESS) { + startActivity(new Intent(getApplicationContext(), ConversationListActivity.class)); + } else if (transferMode == TransferMode.SENDER_SHOW_QR) { + // restart the activities that were removed when BackupTransferActivity was started at (**2) + // (we removed the activity backstack as otherwise a tap on the ArcaneChat icon on the home screen would + // call onNewIntent() which cannot be aborted and will kill BackupTransferActivity. + // if all activities are removed, onCreate() will be called and that can be aborted, so that + // a tap in the home icon just opens BackupTransferActivity. + // (the user can leave ArcaneChat during backup transfer :) + // a proper fix would maybe to not rely onNewIntent() at all - but that would require more refactorings + // and needs lots if testing in complicated areas (share ...)) + startActivity(new Intent(getApplicationContext(), ConversationListActivity.class)); + startActivity(new Intent(this, ApplicationPreferencesActivity.class)); + overridePendingTransition(0, 0); + } + finish(); + } + + public static void appendSSID(Activity activity, final TextView textView) { + if (textView != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + new Thread(() -> { + try { + // depending on the android version, getting the SSID requires none, all or one of + // ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION, NEARBY_WIFI_DEVICES, ACCESS_WIFI_STATE, ACCESS_NETWORK_STATE and maybe even more. + final WifiManager wifiManager = (WifiManager)activity.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (wifiManager.isWifiEnabled()) { + final WifiInfo info = wifiManager.getConnectionInfo(); + final String ssid = info.getSSID(); + Log.i(TAG, "wifi ssid: "+ssid); + if (!ssid.equals("")) { // "" may be returned on insufficient rights + Util.runOnMain(() -> { + textView.setText(textView.getText() + " (" + ssid + ")"); + }); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + }).start(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/QrActivity.java b/src/main/java/org/thoughtcrime/securesms/qr/QrActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..44bd08caeb97328888e2ce34f0a447549859d847 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/QrActivity.java @@ -0,0 +1,264 @@ +package org.thoughtcrime.securesms.qr; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.NotFoundException; +import com.google.zxing.RGBLuminanceSource; +import com.google.zxing.Result; +import com.google.zxing.common.HybridBinarizer; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.NewContactActivity; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.FileNotFoundException; +import java.io.InputStream; + +public class QrActivity extends BaseActionBarActivity implements View.OnClickListener { + + private final static String TAG = QrActivity.class.getSimpleName(); + public final static String EXTRA_SCAN_RELAY = "scan_relay"; + + private final static int REQUEST_CODE_IMAGE = 46243; + private final static int TAB_SHOW = 0; + private final static int TAB_SCAN = 1; + + private TabLayout tabLayout; + private ViewPager viewPager; + private QrShowFragment qrShowFragment; + private boolean scanRelay; + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_qr); + + scanRelay = getIntent().getBooleanExtra(EXTRA_SCAN_RELAY, false); + + qrShowFragment = new QrShowFragment(this); + tabLayout = ViewUtil.findById(this, R.id.tab_layout); + viewPager = ViewUtil.findById(this, R.id.pager); + ProfilePagerAdapter adapter = new ProfilePagerAdapter(this, getSupportFragmentManager()); + viewPager.setAdapter(adapter); + + setSupportActionBar(ViewUtil.findById(this, R.id.toolbar)); + assert getSupportActionBar() != null; + getSupportActionBar().setTitle(scanRelay? R.string.add_transport : R.string.menu_new_contact); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + viewPager.setCurrentItem(scanRelay? TAB_SCAN : TAB_SHOW); + if (scanRelay) tabLayout.setVisibility(View.GONE); + + viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + QrActivity.this.invalidateOptionsMenu(); + checkPermissions(position, adapter, viewPager); + } + + @Override + public void onPageScrollStateChanged(int state) { + } + }); + + tabLayout.setupWithViewPager(viewPager); + } + + private void checkPermissions(int position, ProfilePagerAdapter adapter, ViewPager viewPager) { + if (position == TAB_SCAN) { + Permissions.with(QrActivity.this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_camera_denied)) + .onAllGranted(() -> ((QrScanFragment) adapter.getItem(TAB_SCAN)).handleQrScanWithPermissions(QrActivity.this)) + .onAnyDenied(() -> { + if (scanRelay) { + Toast.makeText(this, getString(R.string.chat_camera_unavailable), Toast.LENGTH_LONG).show(); + } else { + viewPager.setCurrentItem(TAB_SHOW); + } + }) + .execute(); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + getMenuInflater().inflate(R.menu.qr_show, menu); + menu.findItem(R.id.new_classic_contact).setVisible(!DcHelper.getContext(this).isChatmail()); + + Util.redMenuItem(menu, R.id.withdraw); + if(tabLayout.getSelectedTabPosition() == TAB_SCAN) { + menu.findItem(R.id.withdraw).setVisible(false); + } + + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.new_classic_contact) { + this.startActivity(new Intent(this, NewContactActivity.class)); + } else if (itemId == R.id.withdraw) { + qrShowFragment.withdrawQr(); + } else if (itemId == R.id.load_from_image) { + AttachmentManager.selectImage(this, REQUEST_CODE_IMAGE); + } else if (itemId == R.id.paste) { + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.handleQrData(Util.getTextFromClipboard(this)); + } + + return false; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + if (permissions.length > 0 + && Manifest.permission.CAMERA.equals(permissions[0]) + && grantResults[0] == PackageManager.PERMISSION_DENIED) { + if (scanRelay) { + Toast.makeText(this, getString(R.string.chat_camera_unavailable), Toast.LENGTH_LONG).show(); + } else { + viewPager.setCurrentItem(TAB_SHOW); + } + // Workaround because sometimes something else requested the permissions before this class + // (probably the CameraView) and then this class didn't notice when it was denied + } + } + + @Override + public void onActivityResult(int reqCode, int resultCode, final Intent data) { + super.onActivityResult(reqCode, resultCode, data); + + if (resultCode != Activity.RESULT_OK) + return; + + switch (reqCode) { + case REQUEST_CODE_IMAGE: + Uri uri = (data != null ? data.getData() : null); + if (uri != null) { + try { + InputStream inputStream = getContentResolver().openInputStream(uri); + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap == null) { + Log.e(TAG, "uri is not a bitmap: " + uri.toString()); + return; + } + int width = bitmap.getWidth(), height = bitmap.getHeight(); + int[] pixels = new int[width * height]; + bitmap.getPixels(pixels, 0, width, 0, 0, width, height); + bitmap.recycle(); + bitmap = null; + RGBLuminanceSource source = new RGBLuminanceSource(width, height, pixels); + BinaryBitmap bBitmap = new BinaryBitmap(new HybridBinarizer(source)); + MultiFormatReader reader = new MultiFormatReader(); + try { + Result result = reader.decode(bBitmap); + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.handleQrData(result.getText()); + } catch (NotFoundException e) { + Log.e(TAG, "decode exception", e); + Toast.makeText(this, getString(R.string.qrscan_failed), Toast.LENGTH_LONG).show(); + } + } catch (FileNotFoundException e) { + Log.e(TAG, "can not open file: " + uri.toString(), e); + } + } + break; + } + } + + @Override + public void onClick(View v) { + viewPager.setCurrentItem(TAB_SCAN); + } + + private class ProfilePagerAdapter extends FragmentStatePagerAdapter { + + private final QrActivity activity; + + ProfilePagerAdapter(QrActivity activity, FragmentManager fragmentManager) { + super(fragmentManager, FragmentStatePagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.activity = activity; + } + + @NonNull + @Override + public Fragment getItem(int position) { + Fragment fragment; + + switch (position) { + case TAB_SHOW: + fragment = activity.qrShowFragment; + break; + + default: + fragment = new QrScanFragment(); + break; + } + + return fragment; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case TAB_SHOW: + return getString(R.string.qrshow_title); + + default: + return getString(R.string.qrscan_title); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/QrCodeHandler.java b/src/main/java/org/thoughtcrime/securesms/qr/QrCodeHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..46d9e942b67a4001aa35f5ba9374c6ec76ca78d9 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/QrCodeHandler.java @@ -0,0 +1,307 @@ +package org.thoughtcrime.securesms.qr; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.ConversationActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.AccountManager; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.relay.RelayListActivity; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; + +public class QrCodeHandler { + + private static final String TAG = QrCodeHandler.class.getSimpleName(); + + private final Activity activity; + private final DcContext dcContext; + private final Rpc rpc; + private final int accId; + + public QrCodeHandler(Activity activity) { + this.activity = activity; + dcContext = DcHelper.getContext(activity); + rpc = DcHelper.getRpc(activity); + accId = dcContext.getAccountId(); + } + + public void onScanPerformed(IntentResult scanResult) { + if (scanResult == null || scanResult.getFormatName() == null) { + return; // aborted + } + + handleQrData(scanResult.getContents()); + } + + public void handleQrData(String rawString) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final DcLot qrParsed = dcContext.checkQr(rawString); + String name = dcContext.getContact(qrParsed.getId()).getDisplayName(); + switch (qrParsed.getState()) { + case DcContext.DC_QR_ASK_VERIFYCONTACT: + case DcContext.DC_QR_ASK_VERIFYGROUP: + case DcContext.DC_QR_ASK_JOIN_BROADCAST: + showVerifyContactOrGroup(activity, builder, rawString, qrParsed, name); + break; + + case DcContext.DC_QR_FPR_WITHOUT_ADDR: + showVerifyFingerprintWithoutAddress(builder, qrParsed); + break; + + case DcContext.DC_QR_FPR_MISMATCH: + showFingerPrintError(builder, name); + break; + + case DcContext.DC_QR_FPR_OK: + case DcContext.DC_QR_ADDR: + showFingerprintOrQrSuccess(builder, qrParsed, name); + break; + + case DcContext.DC_QR_URL: + showQrUrl(builder, qrParsed); + break; + + case DcContext.DC_QR_ACCOUNT: + case DcContext.DC_QR_LOGIN: + final String scope = qrParsed.getText1(); + setAddTransportDialog(activity, builder, rawString, scope); + builder.setNegativeButton(R.string.cancel, null); + builder.setCancelable(false); + break; + + case DcContext.DC_QR_BACKUP2: + builder.setTitle(R.string.multidevice_receiver_title); + builder.setMessage(activity.getString(R.string.multidevice_receiver_scanning_ask) + "\n\n" + activity.getString(R.string.multidevice_same_network_hint)); + builder.setPositiveButton(R.string.perm_continue, (dialog, which) -> { + AccountManager.getInstance().addAccountFromSecondDevice(activity, rawString); + }); + builder.setNegativeButton(R.string.cancel, null); + builder.setCancelable(false); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + BackupTransferActivity.appendSSID(activity, alertDialog.findViewById(android.R.id.message)); + return; + + case DcContext.DC_QR_BACKUP_TOO_NEW: + builder.setTitle(R.string.multidevice_receiver_title); + builder.setMessage(activity.getString(R.string.multidevice_receiver_needs_update)); + builder.setNegativeButton(R.string.ok, null); + break; + + case DcContext.DC_QR_PROXY: + builder.setTitle(R.string.proxy_use_proxy); + builder.setMessage(activity.getString(R.string.proxy_use_proxy_confirm, qrParsed.getText1())); + builder.setPositiveButton(R.string.proxy_use_proxy, (dlg, btn) -> { + dcContext.setConfigFromQr(rawString); + dcContext.restartIo(); + showDoneToast(activity); + }); + if (rawString.toLowerCase().startsWith("http")) { + builder.setNeutralButton(R.string.open, (d, b) -> IntentUtils.showInBrowser(activity, rawString)); + } + builder.setNegativeButton(R.string.cancel, null); + builder.setCancelable(false); + break; + + case DcContext.DC_QR_WITHDRAW_VERIFYCONTACT: + case DcContext.DC_QR_WITHDRAW_VERIFYGROUP: + case DcContext.DC_QR_WITHDRAW_JOINBROADCAST: + String message = qrParsed.getState() == DcContext.DC_QR_WITHDRAW_VERIFYCONTACT ? activity.getString(R.string.withdraw_verifycontact_explain) + : qrParsed.getState() == DcContext.DC_QR_WITHDRAW_VERIFYCONTACT ? activity.getString(R.string.withdraw_verifygroup_explain, qrParsed.getText1()) + : activity.getString(R.string.withdraw_joinbroadcast_explain, qrParsed.getText1()); + builder.setTitle(R.string.qrshow_title); + builder.setMessage(message); + builder.setNeutralButton(R.string.reset, (dialog, which) -> { + dcContext.setConfigFromQr(rawString); + }); + builder.setPositiveButton(R.string.ok, null); + AlertDialog withdrawDialog = builder.show(); + Util.redButton(withdrawDialog, AlertDialog.BUTTON_NEUTRAL); + return; + + case DcContext.DC_QR_REVIVE_VERIFYCONTACT: + case DcContext.DC_QR_REVIVE_VERIFYGROUP: + case DcContext.DC_QR_REVIVE_JOINBROADCAST: + builder.setTitle(R.string.qrshow_title); + builder.setMessage(activity.getString(R.string.revive_verifycontact_explain)); + builder.setNeutralButton(R.string.revive_qr_code, (dialog, which) -> { + dcContext.setConfigFromQr(rawString); + }); + builder.setPositiveButton(R.string.ok, null); + break; + + default: + handleDefault(builder, rawString, qrParsed); + break; + } + builder.create().show(); + } + + private void handleDefault(AlertDialog.Builder builder, String qrRawString, DcLot qrParsed) { + String msg; + final String scannedText; + switch (qrParsed.getState()) { + case DcContext.DC_QR_ERROR: + scannedText = qrRawString; + msg = qrParsed.getText1() + "\n\n" + activity.getString(R.string.qrscan_contains_text, scannedText); + break; + case DcContext.DC_QR_TEXT: + scannedText = qrParsed.getText1(); + msg = activity.getString(R.string.qrscan_contains_text, scannedText); + break; + default: + scannedText = qrRawString; + msg = activity.getString(R.string.qrscan_contains_text, scannedText); + break; + } + builder.setMessage(msg); + builder.setPositiveButton(android.R.string.ok, null); + builder.setNeutralButton(R.string.menu_copy_to_clipboard, (dialog, which) -> { + Util.writeTextToClipboard(activity, scannedText); + showDoneToast(activity); + }); + } + + private void showQrUrl(AlertDialog.Builder builder, DcLot qrParsed) { + final String url = qrParsed.getText1(); + String msg = String.format(activity.getString(R.string.qrscan_contains_url), url); + builder.setMessage(msg); + builder.setPositiveButton(R.string.open, (dialog, which) -> IntentUtils.showInBrowser(activity, url)); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setNeutralButton(R.string.menu_copy_to_clipboard, (dialog, which) -> { + Util.writeTextToClipboard(activity, url); + showDoneToast(activity); + }); + } + + private void showDoneToast(Activity activity) { + Toast.makeText(activity, activity.getString(R.string.done), Toast.LENGTH_SHORT).show(); + } + + private void showFingerprintOrQrSuccess(AlertDialog.Builder builder, DcLot qrParsed, String name) { + @StringRes int resId = qrParsed.getState() == DcContext.DC_QR_ADDR ? R.string.ask_start_chat_with : R.string.qrshow_x_verified; + builder.setMessage(activity.getString(resId, name)); + builder.setPositiveButton(R.string.start_chat, (dialogInterface, i) -> { + int chatId = dcContext.createChatByContactId(qrParsed.getId()); + Intent intent = new Intent(activity, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + if (qrParsed.getText1Meaning() == DcLot.DC_TEXT1_DRAFT) { + intent.putExtra(ConversationActivity.TEXT_EXTRA, qrParsed.getText1()); + } + activity.startActivity(intent); + }); + builder.setNegativeButton(android.R.string.cancel, null); + } + + private void showFingerPrintError(AlertDialog.Builder builder, String name) { + builder.setMessage(activity.getString(R.string.qrscan_fingerprint_mismatch, name)); + builder.setPositiveButton(android.R.string.ok, null); + } + + private void showVerifyFingerprintWithoutAddress(AlertDialog.Builder builder, DcLot qrParsed) { + builder.setMessage(activity.getString(R.string.qrscan_no_addr_found) + "\n\n" + activity.getString(R.string.qrscan_fingerprint_label) + ":\n" + qrParsed.getText1()); + builder.setPositiveButton(android.R.string.ok, null); + builder.setNeutralButton(R.string.menu_copy_to_clipboard, (dialog, which) -> { + Util.writeTextToClipboard(activity, qrParsed.getText1()); + showDoneToast(activity); + }); + } + + private void showVerifyContactOrGroup(Activity activity, AlertDialog.Builder builder, String qrRawString, DcLot qrParsed, String name) { + String msg; + switch (qrParsed.getState()) { + case DcContext.DC_QR_ASK_VERIFYGROUP: + msg = activity.getString(R.string.qrscan_ask_join_group, qrParsed.getText1()); + break; + case DcContext.DC_QR_ASK_JOIN_BROADCAST: + msg = activity.getString(R.string.qrscan_ask_join_channel, qrParsed.getText1()); + break; + default: + msg = activity.getString(R.string.ask_start_chat_with, name); + break; + } + builder.setMessage(msg); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + DcHelper.getEventCenter(activity).captureNextError(); + int newChatId = dcContext.joinSecurejoin(qrRawString); + DcHelper.getEventCenter(activity).endCaptureNextError(); + + if (newChatId != 0) { + Intent intent = new Intent(activity, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, newChatId); + activity.startActivity(intent); + } else { + AlertDialog.Builder builder1 = new AlertDialog.Builder(activity); + builder1.setMessage(dcContext.getLastError()); + builder1.setPositiveButton(android.R.string.ok, null); + builder1.create().show(); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + } + + private void setAddTransportDialog(Activity activity, AlertDialog.Builder builder, String qrData, String transportName) { + builder.setTitle(R.string.confirm_add_transport); + builder.setMessage(transportName); + builder.setPositiveButton(R.string.ok, (d, w) -> { + ProgressDialog progressDialog = new ProgressDialog(activity); + progressDialog.setMessage(activity.getResources().getString(R.string.one_moment)); + progressDialog.setCanceledOnTouchOutside(false); + progressDialog.setCancelable(false); + String cancel = activity.getResources().getString(android.R.string.cancel); + progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, cancel, (d2, w2) -> { + dcContext.stopOngoingProcess(); + }); + progressDialog.show(); + + Util.runOnAnyBackgroundThread(() -> { + String error = null; + try { + rpc.addTransportFromQr(accId, qrData); + } catch (RpcException e) { + Log.w(TAG, e); + error = e.getMessage(); + } + final String finalError = error; + Util.runOnMain(() -> { + if (!progressDialog.isShowing()) return; // canceled dialog, nothing to do + if (finalError != null) { + new AlertDialog.Builder(activity) + .setTitle(R.string.error) + .setMessage(finalError) + .setPositiveButton(R.string.ok, null) + .show(); + } else { + showDoneToast(activity); + if (!(activity instanceof RelayListActivity)) { + activity.startActivity(new Intent(activity, RelayListActivity.class)); + } + } + try { + progressDialog.dismiss(); + } catch (IllegalArgumentException e) { + // see https://stackoverflow.com/a/5102572/4557005 + Log.w(TAG, e); + } + }); + }); + }); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/QrScanFragment.java b/src/main/java/org/thoughtcrime/securesms/qr/QrScanFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b86056aecfcba7f393d776ddc508bb8279cc2583 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/QrScanFragment.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.qr; + +import android.app.Activity; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.journeyapps.barcodescanner.CaptureManager; +import com.journeyapps.barcodescanner.CompoundBarcodeView; +import com.journeyapps.barcodescanner.DecoratedBarcodeView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class QrScanFragment extends Fragment { + + private static final String TAG = QrScanFragment.class.getSimpleName(); + + private CompoundBarcodeView barcodeScannerView; + private MyCaptureManager capture; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.qr_scan_fragment, container, false); + + barcodeScannerView = view.findViewById(R.id.zxing_barcode_scanner); + barcodeScannerView.setStatusText(getString(R.string.qrscan_hint) + "\n "); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(barcodeScannerView.getStatusView()); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + init(barcodeScannerView, requireActivity(), savedInstanceState); + } + + @Override + public void onResume() { + super.onResume(); + if (capture != null) { + capture.onResume(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (capture != null) { + capture.onPause(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (capture != null) { + capture.onDestroy(); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (capture != null) { + capture.onSaveInstanceState(outState); + } + } + + void handleQrScanWithPermissions(Activity activity) { + if (barcodeScannerView != null) + init(barcodeScannerView, activity, null); + } + + private void init(CompoundBarcodeView barcodeScannerView, Activity activity, Bundle savedInstanceState) { + try { + capture = new MyCaptureManager(activity, barcodeScannerView); + capture.initializeFromIntent(activity.getIntent(), savedInstanceState); + capture.decode(); + } + catch(Exception e) { + Log.w(TAG, e); + } + } + + public class MyCaptureManager extends CaptureManager { + private final Activity myActivity; + + public MyCaptureManager(Activity activity, DecoratedBarcodeView barcodeView) { + super(activity, barcodeView); + myActivity = activity; + } + + // the original implementation of displayFrameworkBugMessageAndExit() calls Activity::finish() + // which makes _showing_ the QR-code impossible if scanning goes wrong. + // therefore, we only show a non-disturbing error here. + @Override + protected void displayFrameworkBugMessageAndExit(String message) { + if (TextUtils.isEmpty(message)) { + message = myActivity.getString(R.string.zxing_msg_camera_framework_bug); + } + Toast.makeText(myActivity, message, Toast.LENGTH_SHORT).show(); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/QrShowActivity.java b/src/main/java/org/thoughtcrime/securesms/qr/QrShowActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..6e8d8db6cb22bdeb26d18dbdb870d0cd0bba1629 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/QrShowActivity.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.qr; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.appcompat.app.ActionBar; + +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class QrShowActivity extends BaseActionBarActivity { + + public final static String CHAT_ID = "chat_id"; + + DcEventCenter dcEventCenter; + + DcContext dcContext; + QrShowFragment fragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_qr_show); + fragment = (QrShowFragment)getSupportFragmentManager().findFragmentById(R.id.qrScannerFragment); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.qrScannerFragment), false, true, false, false); + + dcContext = DcHelper.getContext(this); + dcEventCenter = DcHelper.getEventCenter(this); + + Bundle extras = getIntent().getExtras(); + int chatId = 0; + if (extras != null) { + chatId = extras.getInt(CHAT_ID); + } + + ActionBar supportActionBar = getSupportActionBar(); + assert supportActionBar != null; + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setElevation(0); // edge-to-edge: avoid top shadow + + if (chatId != 0) { + // verified-group + String groupName = dcContext.getChat(chatId).getName(); + supportActionBar.setTitle(groupName); + supportActionBar.setSubtitle(R.string.qrshow_join_group_title); + } else { + // verify-contact + String selfName = DcHelper.get(this, DcHelper.CONFIG_DISPLAY_NAME); // we cannot use MrContact.getDisplayName() as this would result in "Me" instead of + if (selfName.isEmpty()) { + selfName = DcHelper.get(this, DcHelper.CONFIG_CONFIGURED_ADDRESS, "unknown"); + } + supportActionBar.setTitle(selfName); + supportActionBar.setSubtitle(R.string.qrshow_join_contact_title); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.qr_show, menu); + menu.findItem(R.id.new_classic_contact).setVisible(false); + menu.findItem(R.id.paste).setVisible(false); + menu.findItem(R.id.load_from_image).setVisible(false); + Util.redMenuItem(menu, R.id.withdraw); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.withdraw) { + fragment.withdrawQr(); + } + + return false; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/QrShowFragment.java b/src/main/java/org/thoughtcrime/securesms/qr/QrShowFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..8b1704d6449f195e2beafb99cab2635b71840954 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/QrShowFragment.java @@ -0,0 +1,230 @@ +package org.thoughtcrime.securesms.qr; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGImageView; +import com.caverock.androidsvg.SVGParseException; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ScaleStableImageView; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.FileProviderUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.io.FileOutputStream; + +public class QrShowFragment extends Fragment implements DcEventCenter.DcEventDelegate { + + private final static String TAG = QrShowFragment.class.getSimpleName(); + public final static int WHITE = 0xFFFFFFFF; + private final static int BLACK = 0xFF000000; + private final static int WIDTH = 400; + private final static int HEIGHT = 400; + private final static String CHAT_ID = "chat_id"; + + private int chatId = 0; + + private int numJoiners; + + private DcEventCenter dcEventCenter; + + private DcContext dcContext; + + private View.OnClickListener scanClicklistener; + + public QrShowFragment() { + this(null); + } + + public QrShowFragment(View.OnClickListener scanClicklistener) { + super(); + this.scanClicklistener = scanClicklistener; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // keeping the screen on also avoids falling back from IDLE to POLL + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.qr_show_fragment, container, false); + + dcContext = DcHelper.getContext(getActivity()); + dcEventCenter = DcHelper.getEventCenter(getActivity()); + + Bundle extras = getActivity().getIntent().getExtras(); + if (extras != null) { + chatId = extras.getInt(CHAT_ID); + } + + dcEventCenter.addObserver(DcContext.DC_EVENT_SECUREJOIN_INVITER_PROGRESS, this); + + numJoiners = 0; + + ScaleStableImageView backgroundView = view.findViewById(R.id.background); + Drawable drawable = getActivity().getResources().getDrawable(R.drawable.background_hd); + backgroundView.setImageDrawable(drawable); + + SVGImageView imageView = view.findViewById(R.id.qrImage); + try { + SVG svg = SVG.getFromString(fixSVG(dcContext.getSecurejoinQrSvg(chatId))); + imageView.setSVG(svg); + } catch (SVGParseException e) { + e.printStackTrace(); + } catch (Exception e) { + e.printStackTrace(); + Activity activity = getActivity(); + if (activity != null) { + activity.finish(); + } + } + + view.findViewById(R.id.share_link_button).setOnClickListener((v) -> showInviteLinkDialog()); + Button scanBtn = view.findViewById(R.id.scan_qr_button); + if (scanClicklistener != null) { + scanBtn.setVisibility(View.VISIBLE); + scanBtn.setOnClickListener(scanClicklistener); + } else { + scanBtn.setVisibility(View.GONE); + } + + return view; + } + + public static String fixSVG(String svg) { + // HACK: move avatar-letter down, baseline alignment not working, + // see https://github.com/deltachat/deltachat-core-rust/pull/2815#issuecomment-978067378 , + // suggestions welcome :) + return svg.replace("y=\"281.136\"", "y=\"296\""); + } + + public void shareInviteURL() { + try { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + String inviteURL = dcContext.getSecurejoinQr(chatId); + intent.putExtra(Intent.EXTRA_TEXT, inviteURL); + startActivity(Intent.createChooser(intent, getString(R.string.chat_share_with_title))); + } catch (Exception e) { + Log.e(TAG, "failed to share invite URL", e); + } + } + + public void copyQrData() { + String inviteURL = dcContext.getSecurejoinQr(chatId); + Util.writeTextToClipboard(getActivity(), inviteURL); + Toast.makeText(getActivity(), getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + } + + public void withdrawQr() { + Activity activity = getActivity(); + String message; + if (chatId == 0) { + message = activity.getString(R.string.withdraw_verifycontact_explain); + } else { + DcChat chat = dcContext.getChat(chatId); + if (chat.getType() == DcChat.DC_CHAT_TYPE_GROUP) { + message = activity.getString(R.string.withdraw_verifygroup_explain, chat.getName()); + } else { + message = activity.getString(R.string.withdraw_joinbroadcast_explain, chat.getName()); + } + } + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.withdraw_qr_code); + builder.setMessage(message); + builder.setPositiveButton(R.string.reset, (dialog, which) -> { + DcContext dcContext = DcHelper.getContext(activity); + dcContext.setConfigFromQr(dcContext.getSecurejoinQr(chatId)); + activity.finish(); + }); + builder.setNegativeButton(R.string.cancel, null); + AlertDialog dialog = builder.show(); + Util.redPositiveButton(dialog); + } + + public void showInviteLinkDialog() { + View view = View.inflate(getActivity(), R.layout.dialog_share_invite_link, null); + String inviteURL = dcContext.getSecurejoinQr(chatId); + ((TextView)view.findViewById(R.id.invite_link)).setText(inviteURL); + new AlertDialog.Builder(getActivity()) + .setView(view) + .setNegativeButton(R.string.cancel, null) + .setNeutralButton(R.string.menu_copy_to_clipboard, (d, b) -> copyQrData()) + .setPositiveButton(R.string.menu_share, (d, b) -> shareInviteURL()) + .create() + .show(); + } + + @Override + public void onResume() { + super.onResume(); + if (!DcHelper.isNetworkConnected(getContext())) { + Toast.makeText(getActivity(), R.string.qrshow_join_contact_no_connection_toast, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + dcEventCenter.removeObservers(this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_SECUREJOIN_INVITER_PROGRESS) { + DcContext dcContext = DcHelper.getContext(getActivity()); + int contact_id = event.getData1Int(); + long progress = event.getData2Int(); + String msg = null; + if (progress == 300) { + msg = String.format(getString(R.string.qrshow_x_joining), dcContext.getContact(contact_id).getDisplayName()); + numJoiners++; + } else if (progress == 600) { + msg = String.format(getString(R.string.qrshow_x_verified), dcContext.getContact(contact_id).getDisplayName()); + } else if (progress == 800) { + msg = String.format(getString(R.string.qrshow_x_has_joined_group), dcContext.getContact(contact_id).getDisplayName()); + } + + if (msg != null) { + Toast.makeText(getActivity(), msg, Toast.LENGTH_SHORT).show(); + } + + if (progress == 1000) { + numJoiners--; + if (numJoiners <= 0) { + if (getActivity() != null) getActivity().finish(); + } + } + } + + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/qr/RegistrationQrActivity.java b/src/main/java/org/thoughtcrime/securesms/qr/RegistrationQrActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..fccf33d8ddad310e839182ce148c5e18dfea22fe --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/qr/RegistrationQrActivity.java @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.qr; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.journeyapps.barcodescanner.CaptureManager; +import com.journeyapps.barcodescanner.CompoundBarcodeView; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class RegistrationQrActivity extends BaseActionBarActivity { + + public static final String ADD_AS_SECOND_DEVICE_EXTRA = "add_as_second_device"; + public static final String QRDATA_EXTRA = "qrdata"; + + private CaptureManager capture; + + private CompoundBarcodeView barcodeScannerView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + boolean addAsAnotherDevice = getIntent().getBooleanExtra(ADD_AS_SECOND_DEVICE_EXTRA, false); + if (addAsAnotherDevice) { + setContentView(R.layout.activity_registration_2nd_device_qr); + getSupportActionBar().setTitle(R.string.multidevice_receiver_title); + } else { + setContentView(R.layout.activity_registration_qr); + getSupportActionBar().setTitle(R.string.scan_invitation_code); + } + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.layout_container), true, false, true, true); + + barcodeScannerView = findViewById(R.id.zxing_barcode_scanner); + barcodeScannerView.setStatusText(getString(R.string.qrscan_hint) + "\n "); + + View sameNetworkHint = findViewById(R.id.same_network_hint); + if (sameNetworkHint != null) { + BackupTransferActivity.appendSSID(this, findViewById(R.id.same_network_hint)); + } + + if (savedInstanceState != null) { + init(barcodeScannerView, getIntent(), savedInstanceState); + } + + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_camera_denied)) + .onAnyResult(this::handleQrScanWithPermissions) + .onAnyDenied(this::handleQrScanWithDeniedPermission) + .execute(); + } + + private void handleQrScanWithPermissions() { + init(barcodeScannerView, getIntent(), null); + } + + private void handleQrScanWithDeniedPermission() { + setResult(Activity.RESULT_CANCELED); + finish(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.troubleshooting) { + DcHelper.openHelp(this, "#multiclient"); + return true; + } else if (itemId == R.id.menu_paste) { + Intent intent = new Intent(); + intent.putExtra(QRDATA_EXTRA, Util.getTextFromClipboard(this)); + setResult(Activity.RESULT_OK, intent); + finish(); + return true; + } + + return false; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void init(CompoundBarcodeView barcodeScannerView, Intent intent, Bundle savedInstanceState) { + capture = new CaptureManager(this, barcodeScannerView); + capture.initializeFromIntent(intent, savedInstanceState); + capture.decode(); + } + + @Override + protected void onResume() { + super.onResume(); + if (capture != null) { + capture.onResume(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (capture != null) { + capture.onPause(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (capture != null) { + capture.onDestroy(); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + getMenuInflater().inflate(R.menu.registration_qr_activity, menu); + boolean addAsAnotherDevice = getIntent().getBooleanExtra(ADD_AS_SECOND_DEVICE_EXTRA, false); + menu.findItem(R.id.troubleshooting).setVisible(addAsAnotherDevice); + return super.onPrepareOptionsMenu(menu); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (capture != null) { + capture.onSaveInstanceState(outState); + } + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return barcodeScannerView.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/reactions/AddReactionView.java b/src/main/java/org/thoughtcrime/securesms/reactions/AddReactionView.java new file mode 100644 index 0000000000000000000000000000000000000000..0662fc29f7120bbad8e6119d2e28c82d703aece7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/reactions/AddReactionView.java @@ -0,0 +1,192 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; +import androidx.emoji2.emojipicker.EmojiPickerView; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.Reactions; + +public class AddReactionView extends LinearLayout { + private AppCompatTextView[] defaultReactionViews; + private AppCompatTextView anyReactionView; + private boolean anyReactionClearsReaction; + private Context context; + private DcContext dcContext; + private Rpc rpc; + private DcMsg msgToReactTo; + private AddReactionListener listener; + + public AddReactionView(Context context) { + super(context); + } + + public AddReactionView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + private void init() { + if (context == null) { + context = getContext(); + dcContext = DcHelper.getContext(context); + rpc = DcHelper.getRpc(getContext()); + defaultReactionViews = new AppCompatTextView[]{ + findViewById(R.id.reaction_0), + findViewById(R.id.reaction_1), + findViewById(R.id.reaction_2), + findViewById(R.id.reaction_3), + findViewById(R.id.reaction_4), + }; + for (int i = 0; i < defaultReactionViews.length; i++) { + final int ii = i; + defaultReactionViews[i].setOnClickListener(v -> defaultReactionClicked(ii)); + } + anyReactionView = findViewById(R.id.reaction_any); + anyReactionView.setOnClickListener(v -> anyReactionClicked()); + } + } + + public void show(DcMsg msgToReactTo, View parentView, AddReactionListener listener) { + init(); // init delayed as needed + + if ( msgToReactTo.isInfo() + || !dcContext.getChat(msgToReactTo.getChatId()).canSend()) { + return; + } + + this.msgToReactTo = msgToReactTo; + this.listener = listener; + + final String existingReaction = getSelfReaction(); + boolean existingHilited = false; + for (AppCompatTextView defaultReactionView : defaultReactionViews) { + if (defaultReactionView.getText().toString().equals(existingReaction)) { + defaultReactionView.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); + existingHilited = true; + } else { + defaultReactionView.setBackground(null); + } + } + + if (existingReaction != null && !existingHilited) { + anyReactionView.setText(existingReaction); + anyReactionView.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); + anyReactionClearsReaction = true; + } else { + anyReactionView.setText("⋯"); + anyReactionView.setBackground(null); + anyReactionClearsReaction = false; + } + + final int offset = (int)(this.getHeight() * 0.666); + int x = (int)parentView.getX(); + if (msgToReactTo.isOutgoing()) { + x += parentView.getWidth() - offset - this.getWidth(); + } else { + x += offset; + } + ViewUtil.setLeftMargin(this, Math.max(x, 0)); + + int y = Math.max((int)parentView.getY() - offset, offset/2); + ViewUtil.setTopMargin(this, y); + + setVisibility(View.VISIBLE); + } + + public void hide() { + setVisibility(View.GONE); + } + + public void move(int dy) { + if (msgToReactTo != null && getVisibility() == View.VISIBLE) { + ViewUtil.setTopMargin(this, (int) this.getY() - dy); + } + } + + private String getSelfReaction() { + String result = null; + try { + final Reactions reactions = rpc.getMessageReactions(dcContext.getAccountId(), msgToReactTo.getId()); + if (reactions != null) { + final Map> reactionsByContact = reactions.reactionsByContact; + final List selfReactions = reactionsByContact.get(String.valueOf(DcContact.DC_CONTACT_ID_SELF)); + if (selfReactions != null && !selfReactions.isEmpty()) { + result = selfReactions.get(0); + } + } + } catch(RpcException e) { + e.printStackTrace(); + } + return result; + } + + private void defaultReactionClicked(int i) { + final String reaction = defaultReactionViews[i].getText().toString(); + sendReaction(reaction); + + if (listener != null) { + listener.onShallHide(); + } + } + + private void anyReactionClicked() { + if (anyReactionClearsReaction) { + sendReaction(null); + } else { + View pickerLayout = View.inflate(context, R.layout.reaction_picker, null); + + final AlertDialog alertDialog = new AlertDialog.Builder(context) + .setView(pickerLayout) + .setTitle(R.string.react) + .setPositiveButton(R.string.cancel, null) + .create(); + + EmojiPickerView pickerView = ViewUtil.findById(pickerLayout, R.id.emoji_picker); + pickerView.setOnEmojiPickedListener((it) -> { + sendReaction(it.getEmoji()); + alertDialog.dismiss(); + }); + + alertDialog.show(); + } + + if (listener != null) { + listener.onShallHide(); + } + } + + private void sendReaction(final String reaction) { + try { + if (reaction == null || reaction.equals(getSelfReaction())) { + rpc.sendReaction(dcContext.getAccountId(), msgToReactTo.getId(), Collections.singletonList("")); + } else { + rpc.sendReaction(dcContext.getAccountId(), msgToReactTo.getId(), Collections.singletonList(reaction)); + } + } catch(Exception e) { + e.printStackTrace(); + } + } + + public interface AddReactionListener { + void onShallHide(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientItem.java b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientItem.java new file mode 100644 index 0000000000000000000000000000000000000000..872c73681c11e2535b66a3df10423a1c65be1864 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientItem.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContact; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class ReactionRecipientItem extends LinearLayout { + + private AvatarImageView contactPhotoImage; + private TextView nameView; + private TextView reactionView; + + private int contactId; + private String reaction; + + public ReactionRecipientItem(Context context) { + super(context); + } + + public ReactionRecipientItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.contactPhotoImage = findViewById(R.id.contact_photo_image); + this.nameView = findViewById(R.id.name); + this.reactionView = findViewById(R.id.reaction); + + ViewUtil.setTextViewGravityStart(this.nameView, getContext()); + } + + public void bind(@NonNull GlideRequests glideRequests, int contactId, String reaction) { + this.contactId = contactId; + this.reaction = reaction; + DcContact dcContact = DcHelper.getContext(getContext()).getContact(contactId); + Recipient recipient = new Recipient(getContext(), dcContact); + this.contactPhotoImage.setAvatar(glideRequests, recipient, false); + this.reactionView.setText(reaction); + this.nameView.setText(dcContact.getDisplayName()); + } + + public void unbind(GlideRequests glideRequests) { + contactPhotoImage.clear(glideRequests); + } + + public int getContactId() { + return contactId; + } + + public String getReaction() { + return reaction; + } + + public View getReactionView() { + return reactionView; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..4c6c6de5f886176f363b71e3ae78e877ee23b534 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.Pair; + +import java.util.ArrayList; + +public class ReactionRecipientsAdapter extends RecyclerView.Adapter +{ + private @NonNull ArrayList> contactsReactions = new ArrayList<>(); + private final LayoutInflater layoutInflater; + private final ItemClickListener clickListener; + private final GlideRequests glideRequests; + + @Override + public int getItemCount() { + return contactsReactions.size(); + } + + public abstract static class ViewHolder extends RecyclerView.ViewHolder { + + public ViewHolder(View itemView) { + super(itemView); + } + + public abstract void bind(@NonNull GlideRequests glideRequests, int contactId, String reaction); + public abstract void unbind(@NonNull GlideRequests glideRequests); + } + + public static class ReactionViewHolder extends ViewHolder { + + ReactionViewHolder(@NonNull final View itemView, + @Nullable final ItemClickListener clickListener) { + super(itemView); + itemView.setOnClickListener(view -> { + if (clickListener != null) { + clickListener.onItemClick(getView()); + } + }); + ((ReactionRecipientItem) itemView).getReactionView().setOnClickListener(view -> { + if (clickListener != null) { + clickListener.onReactionClick(getView()); + } + }); + } + + public ReactionRecipientItem getView() { + return (ReactionRecipientItem) itemView; + } + + public void bind(@NonNull GlideRequests glideRequests, int contactId, String reaction) { + getView().bind(glideRequests, contactId, reaction); + } + + @Override + public void unbind(@NonNull GlideRequests glideRequests) { + getView().unbind(glideRequests); + } + } + + public ReactionRecipientsAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @Nullable ItemClickListener clickListener) + { + super(); + this.layoutInflater = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.clickListener = clickListener; + } + + @NonNull + @Override + public ReactionRecipientsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ReactionViewHolder(layoutInflater.inflate(R.layout.reaction_recipient_item, parent, false), clickListener); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int i) { + + Pair pair = contactsReactions.get(i); + Integer contactId = pair.first(); + String reaction = pair.second(); + + ViewHolder holder = (ViewHolder) viewHolder; + holder.unbind(glideRequests); + holder.bind(glideRequests, contactId, reaction); + } + + public interface ItemClickListener { + void onItemClick(ReactionRecipientItem item); + void onReactionClick(ReactionRecipientItem item); + } + + public void changeData(ArrayList> contactsReactions) { + this.contactsReactions = contactsReactions==null? new ArrayList<>() : contactsReactions; + notifyDataSetChanged(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java new file mode 100644 index 0000000000000000000000000000000000000000..4c23426eae74cb1e2311848aedfe8093dbb077a0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java @@ -0,0 +1,128 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.List; + +import chat.delta.rpc.types.Reaction; + +public class ReactionsConversationView extends LinearLayout { + + // Normally 6dp, but we have 1dp left+right margin on the pills themselves + private static final int OUTER_MARGIN = ViewUtil.dpToPx(5); + + private final List reactions = new ArrayList<>(); + private boolean isIncoming; + + public ReactionsConversationView(Context context) { + super(context); + init(null); + } + + public ReactionsConversationView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ReactionsConversationView, 0, 0); + isIncoming = typedArray.getInt(R.styleable.ReactionsConversationView_reaction_type, 0) == 2; + } + } + + public void clear() { + this.reactions.clear(); + removeAllViews(); + } + + public void setReactions(List reactions) { + if (reactions.equals(this.reactions)) { + return; + } + + clear(); + this.reactions.addAll(reactions); + + for (Reaction reaction : buildShortenedReactionsList(this.reactions)) { + View pill = buildPill(getContext(), this, reaction); + addView(pill); + } + + if (isIncoming) { + ViewUtil.setLeftMargin(this, OUTER_MARGIN); + } else { + ViewUtil.setRightMargin(this, OUTER_MARGIN); + } + } + + private static @NonNull List buildShortenedReactionsList(@NonNull List reactions) { + if (reactions.size() > 3) { + List shortened = new ArrayList<>(3); + shortened.add(reactions.get(0)); + shortened.add(reactions.get(1)); + int count = 0; + boolean isFromSelf = false; + for (int index = 2; index < reactions.size(); index++) { + count += reactions.get(index).count; + isFromSelf = isFromSelf || reactions.get(index).isFromSelf; + } + Reaction reaction = new Reaction(); + reaction.emoji = null; + reaction.count = count; + reaction.isFromSelf = isFromSelf; + shortened.add(reaction); + + return shortened; + } else { + return reactions; + } + } + + private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction) { + View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false); + AppCompatTextView emojiView = root.findViewById(R.id.reactions_pill_emoji); + TextView countView = root.findViewById(R.id.reactions_pill_count); + View spacer = root.findViewById(R.id.reactions_pill_spacer); + + if (reaction.emoji != null) { + emojiView.setText(reaction.emoji); + + if (reaction.count > 1) { + countView.setText(String.valueOf(reaction.count)); + } else { + countView.setVisibility(GONE); + spacer.setVisibility(GONE); + } + } else { + emojiView.setVisibility(GONE); + spacer.setVisibility(GONE); + countView.setText("+" + reaction.count); + } + + if (reaction.isFromSelf) { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); + countView.setTextColor(ContextCompat.getColor(context, R.color.reaction_pill_text_color_selected)); + } else { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)); + } + + return root; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDetailsFragment.java b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDetailsFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..7cc1a104ec37b7eb3def94d5e43fb2f21b132b6e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDetailsFragment.java @@ -0,0 +1,172 @@ +package org.thoughtcrime.securesms.reactions; + +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.ProfileActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Pair; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.Reactions; + +public class ReactionsDetailsFragment extends DialogFragment implements DcEventCenter.DcEventDelegate { + private static final String TAG = ReactionsDetailsFragment.class.getSimpleName(); + + private RecyclerView recyclerView; + private ReactionRecipientsAdapter adapter; + private final int msgId; + + public ReactionsDetailsFragment(int msgId) { + super(); + this.msgId = msgId; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + adapter = new ReactionRecipientsAdapter(requireActivity(), GlideApp.with(requireActivity()), new ListClickListener()); + + LayoutInflater inflater = requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.reactions_details_fragment, null); + recyclerView = ViewUtil.findById(view, R.id.recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + + recyclerView.setAdapter(adapter); + + refreshData(); + + DcEventCenter eventCenter = DcHelper.getEventCenter(requireContext()); + eventCenter.addObserver(DcContext.DC_EVENT_REACTIONS_CHANGED, this); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.reactions) + .setNegativeButton(R.string.ok, null); + return builder.setView(view).create(); + } + + @Override + public void onDestroy() { + Log.i(TAG, "onDestroy()"); + super.onDestroy(); + DcHelper.getEventCenter(requireActivity()).removeObservers(this); + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId() == DcContext.DC_EVENT_REACTIONS_CHANGED) { + if (event.getData2Int() == msgId) { + refreshData(); + } + } + } + + private void refreshData() { + if (adapter == null) return; + + int accId = DcHelper.getContext(requireActivity()).getAccountId(); + try { + final Reactions reactions = DcHelper.getRpc(requireActivity()).getMessageReactions(accId, msgId); + ArrayList> contactsReactions = new ArrayList<>(); + if (reactions != null) { + Map> reactionsByContact = reactions.reactionsByContact; + List selfReactions = reactionsByContact.remove(String.valueOf(DcContact.DC_CONTACT_ID_SELF)); + for (String contact: reactionsByContact.keySet()) { + for (String reaction: reactionsByContact.get(contact)) { + contactsReactions.add(new Pair<>(Integer.parseInt(contact), reaction)); + } + } + if (selfReactions != null) { + for (String reaction: selfReactions) { + contactsReactions.add(new Pair<>(DcContact.DC_CONTACT_ID_SELF, reaction)); + } + } + } + adapter.changeData(contactsReactions); + } catch (RpcException e) { + e.printStackTrace(); + } + } + + private void openConversation(int contactId) { + Intent intent = new Intent(getContext(), ProfileActivity.class); + intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, contactId); + requireContext().startActivity(intent); + } + + private String getSelfReaction(Rpc rpc, int accId) { + String result = null; + try { + final Reactions reactions = rpc.getMessageReactions(accId, msgId); + if (reactions != null) { + final Map> reactionsByContact = reactions.reactionsByContact; + final List selfReactions = reactionsByContact.get(String.valueOf(DcContact.DC_CONTACT_ID_SELF)); + if (selfReactions != null && !selfReactions.isEmpty()) { + result = selfReactions.get(0); + } + } + } catch(RpcException e) { + e.printStackTrace(); + } + return result; + } + + private void sendReaction(final String reaction) { + Rpc rpc = DcHelper.getRpc(requireActivity()); + DcContext dcContext = DcHelper.getContext(requireActivity()); + int accId = dcContext.getAccountId(); + + try { + if (reaction == null || reaction.equals(getSelfReaction(rpc, accId))) { + rpc.sendReaction(accId, msgId, Collections.singletonList("")); + } else { + rpc.sendReaction(accId, msgId, Collections.singletonList(reaction)); + } + } catch(Exception e) { + e.printStackTrace(); + } + } + + private class ListClickListener implements ReactionRecipientsAdapter.ItemClickListener { + + @Override + public void onItemClick(ReactionRecipientItem item) { + int contactId = item.getContactId(); + if (contactId != DcContact.DC_CONTACT_ID_SELF) { + ReactionsDetailsFragment.this.dismiss(); + openConversation(contactId); + } + } + + @Override + public void onReactionClick(ReactionRecipientItem item) { + sendReaction(item.getReaction()); + ReactionsDetailsFragment.this.dismiss(); + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java new file mode 100644 index 0000000000000000000000000000000000000000..82aa6b29bc10bda27bb28c10a58b394ce31efb3a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 - 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.recipients; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GroupRecordContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.LocalFileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.VcardContactPhoto; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.util.Hash; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.WeakHashMap; + +import chat.delta.rpc.types.VcardContact; + +public class Recipient { + + private final Set listeners = Collections.newSetFromMap(new WeakHashMap()); + + private final @NonNull Address address; + + private final @Nullable String customLabel; + + private @Nullable Uri systemContactPhoto; + private final Uri contactUri; + + private final @Nullable String profileName; + private @Nullable String profileAvatar; + + private final @Nullable DcChat dcChat; + private @Nullable DcContact dcContact; + private final @Nullable VcardContact vContact; + + public static @NonNull Recipient fromChat(@NonNull Context context, int dcMsgId) { + DcContext dcContext = DcHelper.getContext(context); + return new Recipient(context, dcContext.getChat(dcContext.getMsg(dcMsgId).getChatId())); + } + + @SuppressWarnings("ConstantConditions") + public static @NonNull Recipient from(@NonNull Context context, @NonNull Address address) { + if (address == null) throw new AssertionError(address); + DcContext dcContext = DcHelper.getContext(context); + if(address.isDcContact()) { + return new Recipient(context, dcContext.getContact(address.getDcContactId())); + } else if (address.isDcChat()) { + return new Recipient(context, dcContext.getChat(address.getDcChatId())); + } + else if(DcHelper.getContext(context).mayBeValidAddr(address.toString())) { + int contactId = dcContext.lookupContactIdByAddr(address.toString()); + if(contactId!=0) { + return new Recipient(context, dcContext.getContact(contactId)); + } + } + return new Recipient(context, dcContext.getContact(0)); + } + + public Recipient(@NonNull Context context, @NonNull DcChat dcChat) { + this(context, dcChat, null, null, null); + } + + public Recipient(@NonNull Context context, @NonNull VcardContact vContact) { + this(context, null, null, null, vContact); + } + + public Recipient(@NonNull Context context, @NonNull DcContact dcContact) { + this(context, null, dcContact, null, null); + } + + public Recipient(@NonNull Context context, @NonNull DcContact dcContact, @NonNull String profileName) { + this(context, null, dcContact, profileName, null); + } + + private Recipient(@NonNull Context context, @Nullable DcChat dcChat, @Nullable DcContact dcContact, @Nullable String profileName, @Nullable VcardContact vContact) { + this.dcChat = dcChat; + this.dcContact = dcContact; + this.profileName = profileName; + this.vContact = vContact; + this.contactUri = null; + this.systemContactPhoto = null; + this.customLabel = null; + this.profileAvatar = null; + + if(dcContact!=null) { + this.address = Address.fromContact(dcContact.getId()); + maybeSetSystemContactPhoto(context, dcContact); + if (dcContact.getId() == DcContact.DC_CONTACT_ID_SELF) { + setProfileAvatar("SELF"); + } + } + else if(dcChat!=null) { + int chatId = dcChat.getId(); + this.address = Address.fromChat(chatId); + if (!dcChat.isMultiUser()) { + DcContext dcContext = DcHelper.getAccounts(context).getAccount(dcChat.getAccountId()); + int[] contacts = dcContext.getChatContacts(chatId); + if( contacts.length>=1 ) { + this.dcContact = dcContext.getContact(contacts[0]); + maybeSetSystemContactPhoto(context, this.dcContact); + } + } + } + else { + this.address = Address.UNKNOWN; + } + } + + public @Nullable String getName() { + if(dcChat!=null) { + return dcChat.getName(); + } + else if(dcContact!=null) { + return dcContact.getDisplayName(); + } + else if(vContact!=null) { + return vContact.displayName; + } + return ""; + } + + public @Nullable DcContact getDcContact() { + return dcContact; + } + + public @NonNull Address getAddress() { + return address; + } + + public @Nullable String getProfileName() { + return profileName; + } + + public void setProfileAvatar(@Nullable String profileAvatar) { + synchronized (this) { + this.profileAvatar = profileAvatar; + } + + notifyListeners(); + } + + public boolean isMultiUserRecipient() { + return dcChat!=null && dcChat.isMultiUser(); + } + + public synchronized void addListener(RecipientModifiedListener listener) { + listeners.add(listener); + } + + public synchronized void removeListener(RecipientModifiedListener listener) { + listeners.remove(listener); + } + + public synchronized String toShortString() { + return getName(); + } + + public int getFallbackAvatarColor() { + int rgb = 0x00808080; + if(dcChat!=null) { + rgb = dcChat.getColor(); + } + else if(dcContact!=null) { + rgb = dcContact.getColor(); + } + else if(vContact!=null) { + rgb = Color.parseColor(vContact.color); + } + return Color.argb(0xFF, Color.red(rgb), Color.green(rgb), Color.blue(rgb)); + } + + public synchronized @NonNull Drawable getFallbackAvatarDrawable(Context context) { + return getFallbackAvatarDrawable(context, true); + } + + public synchronized @NonNull Drawable getFallbackAvatarDrawable(Context context, boolean roundShape) { + return getFallbackContactPhoto().asDrawable(context, getFallbackAvatarColor(), roundShape); + } + + public synchronized @NonNull GeneratedContactPhoto getFallbackContactPhoto() { + String name = getName(); + if (!TextUtils.isEmpty(profileName)) return new GeneratedContactPhoto(profileName); + else if (!TextUtils.isEmpty(name)) return new GeneratedContactPhoto(name); + else return new GeneratedContactPhoto("#"); + } + + public synchronized @Nullable ContactPhoto getContactPhoto(Context context) { + LocalFileContactPhoto contactPhoto = null; + if (dcChat!=null) { + contactPhoto = new GroupRecordContactPhoto(context, address, dcChat); + } + else if (dcContact!=null) { + contactPhoto = new ProfileContactPhoto(context, address, dcContact); + } + + if (contactPhoto!=null) { + String path = contactPhoto.getPath(context); + if (path != null && !path.isEmpty()) { + return contactPhoto; + } + } + + if (vContact!=null && vContact.profileImage != null) { + return new VcardContactPhoto(vContact); + } + + if (systemContactPhoto != null) { + return new SystemContactPhoto(address, systemContactPhoto, 0); + } + + return null; + } + + private void maybeSetSystemContactPhoto(@NonNull Context context, DcContact contact) { + String identifier = Hash.sha256(contact.getDisplayName() + contact.getAddr()); + Uri systemContactPhoto = Prefs.getSystemContactPhoto(context, identifier); + if (systemContactPhoto != null) { + setSystemContactPhoto(systemContactPhoto); + } + } + + private void setSystemContactPhoto(@Nullable Uri systemContactPhoto) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(systemContactPhoto, this.systemContactPhoto)) { + this.systemContactPhoto = systemContactPhoto; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Recipient)) return false; + + Recipient that = (Recipient) o; + + return this.address.equals(that.address); + } + + @Override + public int hashCode() { + return this.address.hashCode(); + } + + private void notifyListeners() { + Set localListeners; + + synchronized (this) { + localListeners = new HashSet<>(listeners); + } + + for (RecipientModifiedListener listener : localListeners) + listener.onModified(this); + } + + public DcChat getChat() + { + return dcChat!=null? dcChat : new DcChat(0, 0); + } + + @NonNull + @Override + public String toString() { + return "Recipient{" + + "listeners=" + listeners + + ", address=" + address + + ", customLabel='" + customLabel + '\'' + + ", systemContactPhoto=" + systemContactPhoto + + ", contactUri=" + contactUri + + ", profileName='" + profileName + '\'' + + ", profileAvatar='" + profileAvatar + '\'' + + '}'; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java b/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java new file mode 100644 index 0000000000000000000000000000000000000000..d43984dc55a5f0280377d000f3faf1f76f8d6549 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.recipients; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +public interface RecipientForeverObserver { + @MainThread + void onRecipientChanged(@NonNull Recipient recipient); +} diff --git a/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java b/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java new file mode 100644 index 0000000000000000000000000000000000000000..d6e0955c3f9d01f2e9965a3dfc6c6ce51d2e1963 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.recipients; + + +public interface RecipientModifiedListener { + public void onModified(Recipient recipient); +} diff --git a/src/main/java/org/thoughtcrime/securesms/relay/EditRelayActivity.java b/src/main/java/org/thoughtcrime/securesms/relay/EditRelayActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..62c5e905ee2a426762004ac90e12c84c987d87f6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/relay/EditRelayActivity.java @@ -0,0 +1,541 @@ +package org.thoughtcrime.securesms.relay; + +import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED; +import static org.thoughtcrime.securesms.connect.DcHelper.getContext; + +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.util.Patterns; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SwitchCompat; +import androidx.constraintlayout.widget.Group; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcProvider; +import com.google.android.material.textfield.TextInputEditText; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.LogViewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WelcomeActivity; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.proxy.ProxySettingsActivity; +import org.thoughtcrime.securesms.util.IntentUtils; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.util.List; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.EnteredCertificateChecks; +import chat.delta.rpc.types.EnteredLoginParam; +import chat.delta.rpc.types.Socket; + +public class EditRelayActivity extends BaseActionBarActivity implements DcEventCenter.DcEventDelegate { + + private enum VerificationType { + EMAIL, + SERVER, + PORT, + } + + private static final String TAG = EditRelayActivity.class.getSimpleName(); + public final static String EXTRA_ADDR = "extra_addr"; + + private TextInputEditText emailInput; + private TextInputEditText passwordInput; + + private View providerLayout; + private TextView providerHint; + private TextView providerLink; + private @Nullable DcProvider provider; + + private Group advancedGroup; + private ImageView advancedIcon; + private ProgressDialog progressDialog; + private boolean cancelled = false; + + Spinner imapSecurity; + Spinner smtpSecurity; + Spinner certCheck; + + private SwitchCompat proxySwitch; + + Rpc rpc; + int accId; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + rpc = DcHelper.getRpc(this); + accId = DcHelper.getContext(this).getAccountId(); + + setContentView(R.layout.activity_edittransport); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(findViewById(R.id.content_container)); + + + emailInput = findViewById(R.id.email_text); + passwordInput = findViewById(R.id.password_text); + + providerLayout = findViewById(R.id.provider_layout); + providerHint = findViewById(R.id.provider_hint); + providerLink = findViewById(R.id.provider_link); + providerLink.setOnClickListener(l -> onProviderLink()); + + advancedGroup = findViewById(R.id.advanced_group); + advancedIcon = findViewById(R.id.advanced_icon); + TextView advancedTextView = findViewById(R.id.advanced_text); + TextInputEditText imapServerInput = findViewById(R.id.imap_server_text); + TextInputEditText imapPortInput = findViewById(R.id.imap_port_text); + TextInputEditText smtpServerInput = findViewById(R.id.smtp_server_text); + TextInputEditText smtpPortInput = findViewById(R.id.smtp_port_text); + TextView viewLogText = findViewById(R.id.view_log_button); + + imapSecurity = findViewById(R.id.imap_security); + smtpSecurity = findViewById(R.id.smtp_security); + certCheck = findViewById(R.id.cert_check); + + proxySwitch = findViewById(R.id.proxy_settings); + proxySwitch.setOnClickListener(l -> { + proxySwitch.setChecked(!proxySwitch.isChecked()); // revert toggle + startActivity(new Intent(this, ProxySettingsActivity.class)); + }); + + String addr = getIntent().getStringExtra(EXTRA_ADDR); + EnteredLoginParam config = null; + try { + List relays = rpc.listTransports(accId); + for (EnteredLoginParam relay : relays) { + if (addr != null && addr.equals(relay.addr)) { + config = relay; + break; + } + } + if (config == null && !relays.isEmpty()) { + Log.e(TAG, "Error got unknown address: " + addr); + finish(); + return; + }; + } catch (RpcException e) { + Log.e(TAG, "Error calling Rpc.listTransports()", e); + finish(); + return; + } + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle( + config != null? R.string.edit_transport : R.string.manual_account_setup_option + ); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + } + + if (config != null) emailInput.setEnabled(false); + emailInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { } + @Override + public void afterTextChanged(Editable s) { maybeCleanProviderInfo(); } + }); + emailInput.setOnFocusChangeListener((view, focused) -> focusListener(view, focused, VerificationType.EMAIL)); + imapServerInput.setOnFocusChangeListener((view, focused) -> focusListener(view, focused, VerificationType.SERVER)); + imapPortInput.setOnFocusChangeListener((view, focused) -> focusListener(view, focused, VerificationType.PORT)); + smtpServerInput.setOnFocusChangeListener((view, focused) -> focusListener(view, focused, VerificationType.SERVER)); + smtpPortInput.setOnFocusChangeListener((view, focused) -> focusListener(view, focused, VerificationType.PORT)); + advancedTextView.setOnClickListener(l -> onAdvancedSettings()); + advancedIcon.setOnClickListener(l -> onAdvancedSettings()); + advancedIcon.setRotation(45); + viewLogText.setOnClickListener((view) -> showLog()); + + boolean expandAdvanced = false; + int intVal; + + intVal = DcHelper.getInt(this, CONFIG_PROXY_ENABLED); + proxySwitch.setChecked(intVal == 1); + expandAdvanced = expandAdvanced || intVal == 1; + + if (config != null) { // configured + emailInput.setText(config.addr); + if(!TextUtils.isEmpty(config.addr)) { + emailInput.setSelection(config.addr.length(), config.addr.length()); + } + passwordInput.setText(config.password); + + TextInputEditText imapLoginInput = findViewById(R.id.imap_login_text); + imapLoginInput.setText(config.imapUser); + expandAdvanced = expandAdvanced || !TextUtils.isEmpty(config.imapUser); + + imapServerInput.setText(config.imapServer); + expandAdvanced = expandAdvanced || !TextUtils.isEmpty(config.imapServer); + + if (config.imapPort != null) imapPortInput.setText(config.imapPort.toString()); + expandAdvanced = expandAdvanced || config.imapPort != null; + + intVal = socketSecurityToInt(config.imapSecurity); + imapSecurity.setSelection(ViewUtil.checkBounds(intVal, imapSecurity)); + expandAdvanced = expandAdvanced || intVal != 0; + + TextInputEditText smtpLoginInput = findViewById(R.id.smtp_login_text); + smtpLoginInput.setText(config.smtpUser); + expandAdvanced = expandAdvanced || !TextUtils.isEmpty(config.smtpUser); + + TextInputEditText smtpPasswordInput = findViewById(R.id.smtp_password_text); + smtpPasswordInput.setText(config.smtpPassword); + expandAdvanced = expandAdvanced || !TextUtils.isEmpty(config.smtpPassword); + + smtpServerInput.setText(config.smtpServer); + expandAdvanced = expandAdvanced || !TextUtils.isEmpty(config.smtpServer); + + if (config.smtpPort != null) smtpPortInput.setText(config.smtpPort.toString()); + expandAdvanced = expandAdvanced || config.smtpPort != null; + + intVal = socketSecurityToInt(config.smtpSecurity); + smtpSecurity.setSelection(ViewUtil.checkBounds(intVal, smtpSecurity)); + expandAdvanced = expandAdvanced || intVal != 0; + + intVal = certificateChecksToInt(config.certificateChecks); + certCheck.setSelection(ViewUtil.checkBounds(intVal, certCheck)); + expandAdvanced = expandAdvanced || intVal != 0; + } + + if (expandAdvanced) { onAdvancedSettings(); } + registerForEvents(); + } + + private void registerForEvents() { + DcHelper.getEventCenter(this).addObserver(DcContext.DC_EVENT_CONFIGURE_PROGRESS, this); + } + + @Override + public void onResume() { + super.onResume(); + proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1); + } + + private void showLog() { + Intent intent = new Intent(getApplicationContext(), LogViewActivity.class); + startActivity(intent); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + inflater.inflate(R.menu.registration, menu); + super.onPrepareOptionsMenu(menu); + return true; + } + + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.do_register) { + updateProviderInfo(); + onLogin(); + return true; + } else if (id == android.R.id.home) { + // handle close button click here + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void focusListener(View view, boolean focused, VerificationType type) { + + if (!focused) { + TextInputEditText inputEditText = (TextInputEditText) view; + switch (type) { + case EMAIL: + verifyEmail(inputEditText); + updateProviderInfo(); + break; + case SERVER: + verifyServer(inputEditText); + break; + case PORT: + verifyPort(inputEditText); + break; + } + } + } + + private void updateProviderInfo() { + Util.runOnBackground(() -> { + provider = getContext(this).getProviderFromEmailWithDns(emailInput.getText().toString()); + Util.runOnMain(() -> { + if (provider!=null) { + Resources res = getResources(); + providerHint.setText(provider.getBeforeLoginHint()); + switch (provider.getStatus()) { + case DcProvider.DC_PROVIDER_STATUS_PREPARATION: + providerHint.setTextColor(res.getColor(R.color.provider_prep_fg)); + providerLink.setTextColor(res.getColor(R.color.provider_prep_fg)); + providerLayout.setBackgroundColor(res.getColor(R.color.provider_prep_bg)); + providerLayout.setVisibility(View.VISIBLE); + break; + + case DcProvider.DC_PROVIDER_STATUS_BROKEN: + providerHint.setTextColor(res.getColor(R.color.provider_broken_fg)); + providerLink.setTextColor(res.getColor(R.color.provider_broken_fg)); + providerLayout.setBackgroundColor(getResources().getColor(R.color.provider_broken_bg)); + providerLayout.setVisibility(View.VISIBLE); + break; + + default: + providerLayout.setVisibility(View.GONE); + break; + } + } else { + providerLayout.setVisibility(View.GONE); + } + }); + }); + } + + private void maybeCleanProviderInfo() { + if (provider!=null && providerLayout.getVisibility()==View.VISIBLE) { + provider = null; + providerLayout.setVisibility(View.GONE); + } + } + + private void onProviderLink() { + if (provider!=null) { + String url = provider.getOverviewPage(); + if(!url.isEmpty()) { + IntentUtils.showInBrowser(this, url); + } else { + // this should normally not happen + Toast.makeText(this, "ErrProviderWithoutUrl", Toast.LENGTH_LONG).show(); + } + } + } + + private void verifyEmail(TextInputEditText view) { + String error = getString(R.string.login_error_mail); + String email = view.getText().toString(); + if (!DcHelper.getContext(this).mayBeValidAddr(email)) { + view.setError(error); + } + } + + private void verifyServer(TextInputEditText view) { + String error = getString(R.string.login_error_server); + String server = view.getText().toString(); + if (!TextUtils.isEmpty(server) && !Patterns.DOMAIN_NAME.matcher(server).matches() + && !Patterns.IP_ADDRESS.matcher(server).matches() + && !Patterns.WEB_URL.matcher(server).matches() + && !"localhost".equals(server)) { + view.setError(error); + } + } + + private void verifyPort(TextInputEditText view) { + String error = getString(R.string.login_error_port); + String portString = view.getText().toString(); + if (!portString.isEmpty()) { + try { + int port = Integer.valueOf(portString); + if (port < 1 || port > 65535) { + view.setError(error); + } + } catch (NumberFormatException exception) { + view.setError(error); + } + } + } + + private void onAdvancedSettings() { + boolean advancedViewVisible = advancedGroup.getVisibility() == View.VISIBLE; + if (advancedViewVisible) { + advancedGroup.setVisibility(View.GONE); + advancedIcon.setRotation(45); + } else { + advancedGroup.setVisibility(View.VISIBLE); + advancedIcon.setRotation(0); + } + } + + private void onLogin() { + if (!verifyRequiredFields()) { + Toast.makeText(this, R.string.login_error_required_fields, Toast.LENGTH_LONG).show(); + return; + } + + cancelled = false; + setupConfig(); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + progressDialog = new ProgressDialog(this); + progressDialog.setMessage(getString(R.string.one_moment)); + progressDialog.setCanceledOnTouchOutside(false); + progressDialog.setCancelable(false); + progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getString(android.R.string.cancel), (dialog, which) -> { + cancelled = true; + DcHelper.getContext(this).stopOngoingProcess(); + }); + progressDialog.show(); + } + + private boolean verifyRequiredFields() { + String email = emailInput.getText().toString(); + return DcHelper.getContext(this).mayBeValidAddr(email) + && !passwordInput.getText().toString().isEmpty(); + } + + private EnteredCertificateChecks certificateChecksFromInt(int position) { + switch (position) { + case 0: + return EnteredCertificateChecks.automatic; + case 1: + return EnteredCertificateChecks.strict; + case 2: + return EnteredCertificateChecks.acceptInvalidCertificates; + } + throw new IllegalArgumentException("Invalid certificate position: " + position); + } + + private int certificateChecksToInt(EnteredCertificateChecks check) { + if (check == null) return 0; + + switch (check) { + case strict: + return 1; + case acceptInvalidCertificates: + return 2; + case automatic: + default: + return 0; + } + } + + public static Socket socketSecurityFromInt(int position) { + switch (position) { + case 0: + return Socket.automatic; + case 1: + return Socket.ssl; + case 2: + return Socket.starttls; + case 3: + return Socket.plain; + } + throw new IllegalArgumentException("Invalid socketSecurity position: " + position); + } + + public static int socketSecurityToInt(Socket security) { + if (security == null) return 0; + + switch (security) { + case ssl: + return 1; + case starttls: + return 2; + case plain: + return 3; + case automatic: + default: + return 0; + } + } + + private void setupConfig() { + DcHelper.getEventCenter(this).captureNextError(); + + EnteredLoginParam param = new EnteredLoginParam(); + param.addr = getParam(R.id.email_text, true); + param.password = getParam(R.id.password_text, false); + param.imapServer = getParam(R.id.imap_server_text, true); + param.imapPort = Util.objectToInt(getParam(R.id.imap_port_text, true)); + param.imapSecurity = socketSecurityFromInt(imapSecurity.getSelectedItemPosition()); + param.imapUser = getParam(R.id.imap_login_text, false); + param.smtpServer = getParam(R.id.smtp_server_text, true); + param.smtpPort = Util.objectToInt(getParam(R.id.smtp_port_text, true)); + param.smtpSecurity = socketSecurityFromInt(smtpSecurity.getSelectedItemPosition()); + param.smtpUser = getParam(R.id.smtp_login_text, false); + param.smtpPassword = getParam(R.id.smtp_password_text, false); + param.certificateChecks = certificateChecksFromInt(certCheck.getSelectedItemPosition()); + + new Thread(() -> { + try { + rpc.addOrUpdateTransport(accId, param); + DcHelper.getEventCenter(this).endCaptureNextError(); + progressDialog.dismiss(); + Intent conversationList = new Intent(getApplicationContext(), ConversationListActivity.class); + startActivity(conversationList); + finish(); + } catch (RpcException e) { + DcHelper.getEventCenter(this).endCaptureNextError(); + if (!cancelled) { + Util.runOnMain(() -> { + progressDialog.dismiss(); + WelcomeActivity.maybeShowConfigurationError(this, e.getMessage()); + }); + } + } + }).start(); + } + + private String getParam(@IdRes int viewId, boolean doTrim) { + TextInputEditText view = findViewById(viewId); + String value = view.getText().toString(); + if(doTrim) { + value = value.trim(); + } + return value.isEmpty()? null : value; + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (event.getId()==DcContext.DC_EVENT_CONFIGURE_PROGRESS) { + long progress = event.getData1Int(); // progress in permille + int percent = (int)progress / 10; + progressDialog.setMessage(getResources().getString(R.string.one_moment)+String.format(" %d%%", percent)); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/relay/RelayListActivity.java b/src/main/java/org/thoughtcrime/securesms/relay/RelayListActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..d0ae1859d9b7d85bdeed8898e542af45b489f6df --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/relay/RelayListActivity.java @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.relay; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.thoughtcrime.securesms.BaseActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.qr.QrActivity; +import org.thoughtcrime.securesms.qr.QrCodeHandler; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.List; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import chat.delta.rpc.types.EnteredLoginParam; + +public class RelayListActivity extends BaseActionBarActivity + implements RelayListAdapter.OnRelayClickListener, DcEventCenter.DcEventDelegate { + + private static final String TAG = RelayListActivity.class.getSimpleName(); + public static final String EXTRA_QR_DATA = "qr_data"; + + private RelayListAdapter adapter; + private Rpc rpc; + private int accId; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_relay_list); + + rpc = DcHelper.getRpc(this); + accId = DcHelper.getContext(this).getAccountId(); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.transports); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + RecyclerView recyclerView = findViewById(R.id.relay_list); + PulsingFloatingActionButton fabAdd = findViewById(R.id.fab_add_relay); + + // add padding to avoid content hidden behind system bars + ViewUtil.applyWindowInsets(recyclerView); + // Apply insets to prevent fab from being covered by system bars + ViewUtil.applyWindowInsetsAsMargin(fabAdd); + + fabAdd.setOnClickListener(v -> { + new IntentIntegrator(this).setCaptureActivity(QrActivity.class).addExtra(QrActivity.EXTRA_SCAN_RELAY, true).initiateScan(); + }); + + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + // Add the default divider (uses the theme’s `android.R.attr.listDivider`) + DividerItemDecoration divider = new DividerItemDecoration( + recyclerView.getContext(), + layoutManager.getOrientation()); + recyclerView.addItemDecoration(divider); + recyclerView.setLayoutManager(layoutManager); + + adapter = new RelayListAdapter(this); + recyclerView.setAdapter(adapter); + + loadRelays(); + + DcEventCenter eventCenter = DcHelper.getEventCenter(this); + eventCenter.addObserver(DcContext.DC_EVENT_CONFIGURE_PROGRESS, this); + + String qrdata = getIntent().getStringExtra(EXTRA_QR_DATA); + if (qrdata != null) { + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.handleQrData(qrdata); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + DcHelper.getEventCenter(this).removeObservers(this); + } + + private void loadRelays() { + Util.runOnAnyBackgroundThread(() -> { + String mainRelayAddr = ""; + try { + mainRelayAddr = rpc.getConfig(accId, DcHelper.CONFIG_CONFIGURED_ADDRESS); + } catch (RpcException e) { + Log.e(TAG, "RPC.getConfig() failed", e); + } + String finalMainRelayAddr = mainRelayAddr; + + try { + List relays = rpc.listTransports(accId); + + Util.runOnMain(() -> adapter.setRelays(relays, finalMainRelayAddr)); + } catch (RpcException e) { + Log.e(TAG, "RPC.listTransports() failed", e); + Util.runOnMain(() -> adapter.setRelays(null, finalMainRelayAddr)); + } + }); + } + + @Override + public void onRelayClick(EnteredLoginParam relay) { + if (relay.addr != null && !relay.addr.equals(adapter.getMainRelay())) { + Util.runOnAnyBackgroundThread(() -> { + try { + rpc.setConfig(accId, DcHelper.CONFIG_CONFIGURED_ADDRESS, relay.addr); + } catch (RpcException e) { + Log.e(TAG, "RPC.setConfig() failed", e); + } + + loadRelays(); + }); + } + } + + @Override + public void onRelayEdit(EnteredLoginParam relay) { + Intent intent = new Intent(this, EditRelayActivity.class); + intent.putExtra(EditRelayActivity.EXTRA_ADDR, relay.addr); + startActivity(intent); + } + + @Override + public void onRelayDelete(EnteredLoginParam relay) { + new AlertDialog.Builder(this) + .setTitle(R.string.remove_transport) + .setMessage(getString(R.string.confirm_remove_transport, relay.addr)) + .setPositiveButton(R.string.ok, (dialog, which) -> { + try { + rpc.deleteTransport(accId, relay.addr); + loadRelays(); + } catch (RpcException e) { + Log.e(TAG, "RPC.deleteTransport() failed", e); + } + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == IntentIntegrator.REQUEST_CODE) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + QrCodeHandler qrCodeHandler = new QrCodeHandler(this); + qrCodeHandler.onScanPerformed(scanResult); + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + if (eventId == DcContext.DC_EVENT_CONFIGURE_PROGRESS && event.getData1Int() == 1000) { + loadRelays(); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/relay/RelayListAdapter.java b/src/main/java/org/thoughtcrime/securesms/relay/RelayListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..915465bbcab4b62732e8d7fa81821f7411166574 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/relay/RelayListAdapter.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.relay; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.List; + +import chat.delta.rpc.types.EnteredLoginParam; + +public class RelayListAdapter extends RecyclerView.Adapter { + + private List relays = new ArrayList<>(); + private final OnRelayClickListener listener; + private String mainRelayAddr; + + public interface OnRelayClickListener { + void onRelayClick(EnteredLoginParam relay); + void onRelayEdit(EnteredLoginParam relay); + void onRelayDelete(EnteredLoginParam relay); + } + + public RelayListAdapter(OnRelayClickListener listener) { + this.listener = listener; + } + + public String getMainRelay() { + return mainRelayAddr; + } + + public void setRelays(@Nullable List relays, String mainRelayAddr) { + this.relays = relays != null ? relays : new ArrayList<>(); + this.mainRelayAddr = mainRelayAddr; + notifyDataSetChanged(); + } + + @NonNull + @Override + public RelayViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.relay_list_item, parent, false); + return new RelayViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RelayViewHolder holder, int position) { + EnteredLoginParam relay = relays.get(position); + boolean isMain = relay.addr != null && relay.addr.equals(mainRelayAddr); + holder.bind(relay, isMain, listener); + } + + @Override + public int getItemCount() { + return relays.size(); + } + + public static class RelayViewHolder extends RecyclerView.ViewHolder { + private final TextView titleText; + private final TextView subtitleText; + private final ImageView mainIndicator; + private final ImageView editButton; + private final ImageView deleteButton; + + public RelayViewHolder(@NonNull View itemView) { + super(itemView); + titleText = itemView.findViewById(R.id.title); + subtitleText = itemView.findViewById(R.id.subtitle); + mainIndicator = itemView.findViewById(R.id.main_indicator); + editButton = itemView.findViewById(R.id.edit_button); + deleteButton = itemView.findViewById(R.id.delete_button); + } + + public void bind(EnteredLoginParam relay, boolean isMain, OnRelayClickListener listener) { + String[] parts = relay.addr.split("@"); + titleText.setText(parts.length == 2? parts[1] : parts[0]); + subtitleText.setText(parts.length == 2? parts[0] : ""); + mainIndicator.setVisibility(isMain ? View.VISIBLE : View.INVISIBLE); + deleteButton.setVisibility(isMain ? View.GONE : View.VISIBLE); + + itemView.setOnClickListener(v -> { + if (listener != null) { + listener.onRelayClick(relay); + } + }); + + editButton.setOnClickListener(v -> { + if (listener != null) { + listener.onRelayEdit(relay); + } + }); + + deleteButton.setOnClickListener(v -> { + if (listener != null) { + listener.onRelayDelete(relay); + } + }); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..3e299cfd7f008850c6a37c09d72c9d571b151fc7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -0,0 +1,362 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.ImageEditorMediaConstraints; +import org.thoughtcrime.securesms.imageeditor.ImageEditorView; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import static android.app.Activity.RESULT_OK; + +public final class ImageEditorFragment extends Fragment implements ImageEditorHud.EventListener, + VerticalSlideColorPicker.OnColorChangeListener{ + + private static final String TAG = ImageEditorFragment.class.getSimpleName(); + + private static final String KEY_IMAGE_URI = "image_uri"; + + public static final int SELECT_STICKER_REQUEST_CODE = 123; + + private EditorModel restoredModel; + + @Nullable + private EditorElement currentSelection; + private int imageMaxHeight; + private int imageMaxWidth; + + public static class Data { + private final Bundle bundle; + + Data(Bundle bundle) { + this.bundle = bundle; + } + + public Data() { + this(new Bundle()); + } + + void writeModel(@NonNull EditorModel model) { + byte[] bytes = ParcelUtil.serialize(model); + bundle.putByteArray("MODEL", bytes); + } + + @Nullable + public EditorModel readModel() { + byte[] bytes = bundle.getByteArray("MODEL"); + if (bytes == null) { + return null; + } + return ParcelUtil.deserialize(bytes, EditorModel.CREATOR); + } + } + + private Uri imageUri; + private ImageEditorHud imageEditorHud; + private ImageEditorView imageEditorView; + private boolean cropAvatar; + + public static ImageEditorFragment newInstance(@NonNull Uri imageUri, boolean cropAvatar) { + Bundle args = new Bundle(); + args.putParcelable(KEY_IMAGE_URI, imageUri); + + ImageEditorFragment fragment = new ImageEditorFragment(); + fragment.cropAvatar = cropAvatar; + fragment.setArguments(args); + fragment.setUri(imageUri); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (imageUri == null) { + imageUri = getArguments().getParcelable(KEY_IMAGE_URI); + } + + MediaConstraints mediaConstraints = new ImageEditorMediaConstraints(); + + imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext()); + imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.image_editor_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + imageEditorHud = view.findViewById(R.id.scribble_hud); + imageEditorView = view.findViewById(R.id.image_editor_view); + + imageEditorHud.setEventListener(this); + + imageEditorView.setTapListener(selectionListener); + imageEditorView.setDrawingChangedListener(this::refreshUniqueColors); + imageEditorView.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); + + EditorModel editorModel = null; + + if (restoredModel != null) { + editorModel = restoredModel; + restoredModel = null; + } else if (savedInstanceState != null) { + editorModel = new Data(savedInstanceState).readModel(); + } + + if (editorModel == null) { + editorModel = cropAvatar? EditorModel.createForCircleEditing() : new EditorModel(); + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight)); + image.getFlags().setSelectable(false).persist(); + editorModel.addElement(image); + } + + imageEditorView.setModel(editorModel); + + refreshUniqueColors(); + if (cropAvatar) { + imageEditorHud.setMode(ImageEditorHud.Mode.CROP); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + new Data(outState).writeModel(imageEditorView.getModel()); + } + + public void setUri(@NonNull Uri uri) { + this.imageUri = uri; + } + + @NonNull + public Uri getUri() { + return imageUri; + } + + private void changeEntityColor(int selectedColor) { + if (currentSelection != null) { + Renderer renderer = currentSelection.getRenderer(); + if (renderer instanceof ColorableRenderer) { + ((ColorableRenderer) renderer).setColor(selectedColor); + refreshUniqueColors(); + } + } + } + + private void startTextEntityEditing(@NonNull EditorElement textElement, boolean selectAll) { + imageEditorView.startTextEditing(textElement, Prefs.isIncognitoKeyboardEnabled(getContext()), selectAll); + } + + protected void addText() { + String initialText = ""; + int color = imageEditorHud.getActiveColor(); + MultiLineTextRenderer renderer = new MultiLineTextRenderer(initialText, color); + EditorElement element = new EditorElement(renderer); + + imageEditorView.getModel().addElementCentered(element, 1); + imageEditorView.invalidate(); + + currentSelection = element; + + startTextEntityEditing(element, true); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + imageEditorHud.enterMode(ImageEditorHud.Mode.NONE); + } + + @Override + public void onModeStarted(@NonNull ImageEditorHud.Mode mode) { + imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); + imageEditorView.doneTextEditing(); + + switch (mode) { + case CROP: + imageEditorView.getModel().startCrop(); + break; + + case DRAW: + imageEditorView.startDrawing(0.01f, Paint.Cap.ROUND, false); + break; + + case HIGHLIGHT: + imageEditorView.startDrawing(0.03f, Paint.Cap.SQUARE, false); + break; + + case BLUR: { + imageEditorView.startDrawing(0.075f, Paint.Cap.ROUND, true); + break; + } + + case TEXT: + addText(); + break; + + case NONE: + imageEditorView.getModel().doneCrop(); + currentSelection = null; + break; + } + } + + @Override + public void onSave() { + Util.runOnBackground(() -> { + Activity activity = ImageEditorFragment.this.getActivity(); + if (activity == null) { + return; + } + Bitmap bitmap = imageEditorView.getModel().render(activity); + PersistentBlobProvider provider = PersistentBlobProvider.getInstance(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); + + byte[] data = baos.toByteArray(); + baos = null; + bitmap = null; + + Uri uri = null; + if (cropAvatar) { + File file = new File(activity.getCacheDir(), "cropped"); + try { + FileOutputStream stream = new FileOutputStream(file); + stream.write(data); + stream.flush(); + stream.close(); + uri = Uri.fromFile(file); + } catch (IOException e) { + e.printStackTrace(); + return; + } + } else { + uri = provider.create(activity, data, MediaUtil.IMAGE_JPEG, null); + } + + Intent intent = new Intent(); + intent.setData(uri); + activity.setResult(RESULT_OK, intent); + activity.finish(); + }); + } + + @Override + public void onColorChange(int color) { + imageEditorView.setDrawingBrushColor(color); + changeEntityColor(color); + } + + @Override + public void onUndo() { + imageEditorView.getModel().undo(); + refreshUniqueColors(); + } + + @Override + public void onDelete() { + imageEditorView.deleteElement(currentSelection); + refreshUniqueColors(); + } + + @Override + public void onFlipHorizontal() { + imageEditorView.getModel().flipHorizontal(); + } + + @Override + public void onRotate90AntiClockwise() { + imageEditorView.getModel().rotate90anticlockwise(); + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + + } + + private void refreshUniqueColors() { + imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); + } + + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + imageEditorHud.setUndoAvailability(undoAvailable); + } + + private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() { + + @Override + public void onEntityDown(@Nullable EditorElement editorElement) { + if (editorElement == null) { + currentSelection = null; + imageEditorHud.enterMode(ImageEditorHud.Mode.NONE); + imageEditorView.doneTextEditing(); + } + } + + @Override + public void onEntitySingleTap(@Nullable EditorElement editorElement) { + currentSelection = editorElement; + if (currentSelection != null) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing()); + } else { + imageEditorHud.enterMode(ImageEditorHud.Mode.MOVE_DELETE); + } + } + } + + @Override + public void onEntityDoubleTap(@NonNull EditorElement editorElement) { + currentSelection = editorElement; + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true); + } + } + + private void setTextElement(@NonNull EditorElement editorElement, + @NonNull ColorableRenderer colorableRenderer, + boolean startEditing) + { + int color = colorableRenderer.getColor(); + imageEditorHud.enterMode(ImageEditorHud.Mode.TEXT); + imageEditorHud.setActiveColor(color); + if (startEditing) { + startTextEntityEditing(editorElement, false); + } + } + }; +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java new file mode 100644 index 0000000000000000000000000000000000000000..4a987b67aa042d2479bc64aa8c5445512742d795 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -0,0 +1,292 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * The HUD (heads-up display) that contains all of the tools for interacting with + * {@link org.thoughtcrime.securesms.imageeditor.ImageEditorView} + */ +public final class ImageEditorHud extends LinearLayout { + + private View cropButton; + private View cropFlipButton; + private View cropRotateButton; + private View drawButton; + private View highlightButton; + private View blurButton; + private View textButton; + private View undoButton; + private View saveButton; + private View deleteButton; + private View confirmButton; + private VerticalSlideColorPicker colorPicker; + private RecyclerView colorPalette; + private View scribbleBlurHelpText; + + @NonNull + private EventListener eventListener = NULL_EVENT_LISTENER; + @Nullable + private ColorPaletteAdapter colorPaletteAdapter; + + private final Map> visibilityModeMap = new HashMap<>(); + private final Set allViews = new HashSet<>(); + + private Mode currentMode; + private boolean undoAvailable; + + public ImageEditorHud(@NonNull Context context) { + super(context); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.image_editor_hud, this); + setOrientation(VERTICAL); + + cropButton = findViewById(R.id.scribble_crop_button); + cropFlipButton = findViewById(R.id.scribble_crop_flip); + cropRotateButton = findViewById(R.id.scribble_crop_rotate); + colorPalette = findViewById(R.id.scribble_color_palette); + drawButton = findViewById(R.id.scribble_draw_button); + highlightButton = findViewById(R.id.scribble_highlight_button); + blurButton = findViewById(R.id.scribble_blur_button); + textButton = findViewById(R.id.scribble_text_button); + undoButton = findViewById(R.id.scribble_undo_button); + saveButton = findViewById(R.id.scribble_save_button); + deleteButton = findViewById(R.id.scribble_delete_button); + confirmButton = findViewById(R.id.scribble_confirm_button); + colorPicker = findViewById(R.id.scribble_color_picker); + scribbleBlurHelpText = findViewById(R.id.scribble_blur_help_text); + + initializeViews(); + initializeVisibilityMap(); + setMode(Mode.NONE); + } + + private void initializeVisibilityMap() { + setVisibleViewsWhenInMode(Mode.NONE, drawButton, highlightButton, blurButton, textButton, cropButton, undoButton, saveButton); + + setVisibleViewsWhenInMode(Mode.DRAW, confirmButton, undoButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.HIGHLIGHT, confirmButton, undoButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.BLUR, confirmButton, undoButton, scribbleBlurHelpText); + + setVisibleViewsWhenInMode(Mode.TEXT, confirmButton, deleteButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.MOVE_DELETE, confirmButton, deleteButton); + + setVisibleViewsWhenInMode(Mode.CROP, confirmButton, cropFlipButton, cropRotateButton, undoButton); + + for (Set views : visibilityModeMap.values()) { + allViews.addAll(views); + } + } + + private void setVisibleViewsWhenInMode(Mode mode, View... views) { + visibilityModeMap.put(mode, new HashSet<>(Arrays.asList(views))); + } + + private void initializeViews() { + undoButton.setOnClickListener(v -> eventListener.onUndo()); + + deleteButton.setOnClickListener(v -> { + eventListener.onDelete(); + setMode(Mode.NONE); + }); + + cropButton.setOnClickListener(v -> setMode(Mode.CROP)); + cropFlipButton.setOnClickListener(v -> eventListener.onFlipHorizontal()); + cropRotateButton.setOnClickListener(v -> eventListener.onRotate90AntiClockwise()); + + confirmButton.setOnClickListener(v -> setMode(Mode.NONE)); + + colorPaletteAdapter = new ColorPaletteAdapter(); + colorPaletteAdapter.setEventListener(colorPicker::setActiveColor); + + colorPalette.setLayoutManager(new LinearLayoutManager(getContext())); + colorPalette.setAdapter(colorPaletteAdapter); + + drawButton.setOnClickListener(v -> setMode(Mode.DRAW)); + blurButton.setOnClickListener(v -> setMode(Mode.BLUR)); + highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT)); + textButton.setOnClickListener(v -> setMode(Mode.TEXT)); + saveButton.setOnClickListener(v -> eventListener.onSave()); + } + + public void setColorPalette(@NonNull Set colors) { + if (colorPaletteAdapter != null) { + colorPaletteAdapter.setColors(colors); + } + } + + public int getActiveColor() { + return colorPicker.getActiveColor(); + } + + public void setActiveColor(int color) { + colorPicker.setActiveColor(color); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener != null ? eventListener : NULL_EVENT_LISTENER; + } + + public void enterMode(@NonNull Mode mode) { + setMode(mode, false); + } + + public void setMode(@NonNull Mode mode) { + setMode(mode, true); + } + + private void setMode(@NonNull Mode mode, boolean notify) { + this.currentMode = mode; + updateButtonVisibility(mode); + + switch (mode) { + case DRAW: presentModeDraw(); break; + case HIGHLIGHT: presentModeHighlight(); break; + case TEXT: presentModeText(); break; + case BLUR: presentModeBlur(); break; + } + + if (notify) { + eventListener.onModeStarted(mode); + } + eventListener.onRequestFullScreen(mode != Mode.NONE, mode != Mode.TEXT); + } + + private void updateButtonVisibility(@NonNull Mode mode) { + Set visibleButtons = visibilityModeMap.get(mode); + for (View button : allViews) { + button.setVisibility(buttonIsVisible(visibleButtons, button) ? VISIBLE : GONE); + } + } + + private boolean buttonIsVisible(@Nullable Set visibleButtons, @NonNull View button) { + return visibleButtons != null && + visibleButtons.contains(button) && + (button != undoButton || undoAvailable); + } + + private void presentModeBlur() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.WHITE); + } + + private void presentModeDraw() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.RED); + } + + private void presentModeHighlight() { + colorPicker.setOnColorChangeListener(highlightOnColorChangeListener); + colorPicker.setActiveColor(Color.YELLOW); + } + + private void presentModeText() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.WHITE); + } + + private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = selectedColor -> eventListener.onColorChange(selectedColor); + + private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = selectedColor -> eventListener.onColorChange(replaceAlphaWith128(selectedColor)); + + private static int replaceAlphaWith128(int color) { + return color & ~0xff000000 | 0x80000000; + } + + public void setUndoAvailability(boolean undoAvailable) { + this.undoAvailable = undoAvailable; + + undoButton.setVisibility(buttonIsVisible(visibilityModeMap.get(currentMode), undoButton) ? VISIBLE : GONE); + } + + public enum Mode { + NONE, + CROP, + TEXT, + DRAW, + HIGHLIGHT, + BLUR, + MOVE_DELETE, + } + + public interface EventListener { + void onModeStarted(@NonNull Mode mode); + void onColorChange(int color); + void onUndo(); + void onDelete(); + void onSave(); + void onFlipHorizontal(); + void onRotate90AntiClockwise(); + void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard); + } + + private static final EventListener NULL_EVENT_LISTENER = new EventListener() { + + @Override + public void onModeStarted(@NonNull Mode mode) { + } + + @Override + public void onColorChange(int color) { + } + + @Override + public void onUndo() { + } + + @Override + public void onDelete() { + } + + @Override + public void onSave() { + } + + @Override + public void onFlipHorizontal() { + } + + @Override + public void onRotate90AntiClockwise() { + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + } + }; +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java b/src/main/java/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..e278fb67608295b0290eaafe51130afdd2894fb1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.os.Bundle; + +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; + +public class ScribbleActivity extends PassphraseRequiredActionBarActivity { + public static final int SCRIBBLE_REQUEST_CODE = 31424; + public static final String CROP_AVATAR = "crop_avatar"; + ImageEditorFragment imageEditorFragment; + + protected boolean allowInLockedMode() { + return getIntent().getBooleanExtra(CROP_AVATAR, false); + } + + @Override + protected void onPreCreate() { + dynamicTheme = new DynamicNoActionBarTheme(); + super.onPreCreate(); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.scribble_activity); + boolean cropAvatar = getIntent().getBooleanExtra(CROP_AVATAR, false); + imageEditorFragment = initFragment(R.id.scribble_container, ImageEditorFragment.newInstance(getIntent().getData(), cropAvatar)); + } + +/* @Override + public void onBackPressed() { + if (!imageEditorFragment.onBackPressed()) { + super.onBackPressed(); + } + } */ +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/StickerLoader.java b/src/main/java/org/thoughtcrime/securesms/scribbles/StickerLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..8e0ec7e4c57824cd098e882358270c0e7475b06a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/StickerLoader.java @@ -0,0 +1,56 @@ +/** + * Copyright (C) 2016 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.scribbles; + + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Log; + +import org.thoughtcrime.securesms.util.AsyncLoader; + +import java.io.IOException; + +class StickerLoader extends AsyncLoader { + + private static final String TAG = StickerLoader.class.getName(); + + private final String assetDirectory; + + StickerLoader(Context context, String assetDirectory) { + super(context); + this.assetDirectory = assetDirectory; + } + + @Override + public @NonNull + String[] loadInBackground() { + try { + String[] files = getContext().getAssets().list(assetDirectory); + + for (int i=0;i. + */ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; + +import com.google.android.material.tabs.TabLayout; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; +import android.view.MenuItem; + +import org.thoughtcrime.securesms.R; + +public class StickerSelectActivity extends FragmentActivity implements StickerSelectFragment.StickerSelectionListener { + + private static final String TAG = StickerSelectActivity.class.getSimpleName(); + + public static final String EXTRA_STICKER_FILE = "extra_sticker_file"; + + private static final int[] TAB_TITLES = new int[] { + R.drawable.ic_tag_faces_white_24dp, + R.drawable.ic_work_white_24dp, + R.drawable.ic_pets_white_24dp, + R.drawable.ic_local_dining_white_24dp, + R.drawable.ic_wb_sunny_white_24dp + }; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.scribble_select_sticker_activity); + + ViewPager viewPager = findViewById(R.id.camera_sticker_pager); + viewPager.setAdapter(new StickerPagerAdapter(getSupportFragmentManager(), this)); + + TabLayout tabLayout = findViewById(R.id.camera_sticker_tabs); + tabLayout.setupWithViewPager(viewPager); + + for (int i=0;i. + */ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; + +public class StickerSelectFragment extends Fragment implements LoaderManager.LoaderCallbacks { + + private RecyclerView recyclerView; + private GlideRequests glideRequests; + private String assetDirectory; + private StickerSelectionListener listener; + + public static StickerSelectFragment newInstance(String assetDirectory) { + StickerSelectFragment fragment = new StickerSelectFragment(); + + Bundle args = new Bundle(); + args.putString("assetDirectory", assetDirectory); + fragment.setArguments(args); + + return fragment; + } + + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + View view = inflater.inflate(R.layout.scribble_select_sticker_fragment, container, false); + this.recyclerView = view.findViewById(R.id.stickers_recycler_view); + + return view; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + this.glideRequests = GlideApp.with(this); + this.assetDirectory = getArguments().getString("assetDirectory"); + + getLoaderManager().initLoader(0, null, this); + this.recyclerView.setLayoutManager(new GridLayoutManager(getActivity(), 3)); + } + + @Override + public @NonNull Loader onCreateLoader(int id, Bundle args) { + return new StickerLoader(getActivity(), assetDirectory); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, String[] data) { + recyclerView.setAdapter(new StickersAdapter(getActivity(), glideRequests, data)); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + recyclerView.setAdapter(null); + } + + public void setListener(StickerSelectionListener listener) { + this.listener = listener; + } + + class StickersAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final String[] stickerFiles; + private final LayoutInflater layoutInflater; + + StickersAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, @NonNull String[] stickerFiles) { + this.glideRequests = glideRequests; + this.stickerFiles = stickerFiles; + this.layoutInflater = LayoutInflater.from(context); + } + + @Override + public @NonNull StickerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new StickerViewHolder(layoutInflater.inflate(R.layout.scribble_sticker_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull StickerViewHolder holder, int position) { + holder.fileName = stickerFiles[position]; + + glideRequests.load(Uri.parse("file:///android_asset/" + holder.fileName)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(holder.image); + } + + @Override + public int getItemCount() { + return stickerFiles.length; + } + + @Override + public void onViewRecycled(@NonNull StickerViewHolder holder) { + super.onViewRecycled(holder); + glideRequests.clear(holder.image); + } + + private void onStickerSelected(String fileName) { + if (listener != null) listener.onStickerSelected(fileName); + } + + class StickerViewHolder extends RecyclerView.ViewHolder { + + private String fileName; + private final ImageView image; + + StickerViewHolder(View itemView) { + super(itemView); + image = itemView.findViewById(R.id.sticker_image); + itemView.setOnClickListener(view -> { + int pos = getAdapterPosition(); + if (pos >= 0) { + onStickerSelected(fileName); + } + }); + } + } + } + + interface StickerSelectionListener { + void onStickerSelected(String name); + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java b/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..b822122df8cb97fad4ad7cd762a6f5e7b87a0c5a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java @@ -0,0 +1,311 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Parcel; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.util.BitmapUtil; + +import java.util.concurrent.ExecutionException; + +/** + * Uses Glide to load an image and implements a {@link Renderer}. + * + * The image can be encrypted. + */ +final class UriGlideRenderer implements Renderer { + + private static final String TAG = UriGlideRenderer.class.getSimpleName(); + + private static final int PREVIEW_DIMENSION_LIMIT = 2048; + private static final int MAX_BLUR_DIMENSION = 300; + + private final Uri imageUri; + private final Paint paint = new Paint(); + private final Matrix imageProjectionMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final Matrix blurScaleMatrix = new Matrix(); + private final boolean decryptable; + private final int maxWidth; + private final int maxHeight; + + @Nullable private Bitmap bitmap; + @Nullable private Bitmap blurredBitmap; + @Nullable private Paint blurPaint; + + UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) { + this.imageUri = imageUri; + this.decryptable = decryptable; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + paint.setAntiAlias(true); + paint.setFilterBitmap(true); + paint.setDither(true); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + if (getBitmap() == null) { + if (rendererContext.isBlockingLoad()) { + try { + Bitmap bitmap = getBitmapGlideRequest(rendererContext.context, false).submit().get(); + setBitmap(rendererContext, bitmap); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } else { + getBitmapGlideRequest(rendererContext.context, true).into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + setBitmap(rendererContext, resource); + + rendererContext.invalidate.onInvalidate(UriGlideRenderer.this); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + bitmap = null; + } + }); + } + } + + final Bitmap bitmap = getBitmap(); + if (bitmap != null) { + rendererContext.save(); + + rendererContext.canvasMatrix.concat(imageProjectionMatrix); + + // Units are image level pixels at this point. + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawBitmap(bitmap, 0, 0, rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint() : paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + + renderBlurOverlay(rendererContext); + } else if (rendererContext.isBlockingLoad()) { + // If failed to load, we draw a black out, in case image was sticker positioned to cover private info. + rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint); + } + } + + private void renderBlurOverlay(RendererContext rendererContext) { + boolean renderMask = false; + + for (EditorElement child : rendererContext.getChildren()) { + if (child.getZOrder() == EditorModel.Z_MASK) { + renderMask = true; + if (blurPaint == null) { + blurPaint = new Paint(); + blurPaint.setAntiAlias(true); + blurPaint.setFilterBitmap(true); + blurPaint.setDither(true); + } + blurPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + rendererContext.setMaskPaint(blurPaint); + child.draw(rendererContext); + } + } + + if (renderMask) { + rendererContext.save(); + rendererContext.canvasMatrix.concat(imageProjectionMatrix); + + blurPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP)); + blurPaint.setMaskFilter(null); + + if (blurredBitmap == null) { + blurredBitmap = blur(bitmap, rendererContext.context); + + blurScaleMatrix.setRectToRect(new RectF(0, 0, blurredBitmap.getWidth(), blurredBitmap.getHeight()), + new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()), + Matrix.ScaleToFit.FILL); + } + + rendererContext.canvas.concat(blurScaleMatrix); + rendererContext.canvas.drawBitmap(blurredBitmap, 0, 0, blurPaint); + blurPaint.setXfermode(null); + + rendererContext.restore(); + } + } + + private GlideRequest getBitmapGlideRequest(@NonNull Context context, boolean preview) { + int width = this.maxWidth; + int height = this.maxHeight; + + if (preview) { + width = Math.min(width, PREVIEW_DIMENSION_LIMIT); + height = Math.min(height, PREVIEW_DIMENSION_LIMIT); + } + + return GlideApp.with(context) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .override(width, height) + .centerInside() + .load(decryptable && imageUri!=null ? new DecryptableStreamUriLoader.DecryptableUri(imageUri) : imageUri); + } + + @Override + public boolean hitTest(float x, float y) { + return pixelAlphaNotZero(x, y); + } + + private boolean pixelAlphaNotZero(float x, float y) { + Bitmap bitmap = getBitmap(); + + if (bitmap == null) return false; + + imageProjectionMatrix.invert(temp); + + float[] onBmp = new float[2]; + temp.mapPoints(onBmp, new float[]{ x, y }); + + int xInt = (int) onBmp[0]; + int yInt = (int) onBmp[1]; + + if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) { + return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0; + } else { + return false; + } + } + + /** + * Always use this getter, as Bitmap is kept in Glide's LRUCache, so it could have been recycled + * by Glide. If it has, or was never set, this method returns null. + */ + public @Nullable Bitmap getBitmap() { + if (bitmap != null && bitmap.isRecycled()) { + bitmap = null; + } + return bitmap; + } + + private void setBitmap(@NonNull RendererContext rendererContext, @Nullable Bitmap bitmap) { + this.bitmap = bitmap; + if (bitmap != null) { + RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()); + imageProjectionMatrix.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + rendererContext.rendererReady.onReady(UriGlideRenderer.this, cropMatrix(bitmap), new Point(bitmap.getWidth(), bitmap.getHeight())); + } + } + + private static Matrix cropMatrix(Bitmap bitmap) { + Matrix matrix = new Matrix(); + if (bitmap.getWidth() > bitmap.getHeight()) { + matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth()); + } else { + matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1); + } + return matrix; + } + + @RequiresApi(17) + private static @NonNull Bitmap blur(Bitmap bitmap, Context context) { + Point previewSize = scaleKeepingAspectRatio(new Point(bitmap.getWidth(), bitmap.getHeight()), PREVIEW_DIMENSION_LIMIT); + Point blurSize = scaleKeepingAspectRatio(new Point(previewSize.x / 2, previewSize.y / 2 ), MAX_BLUR_DIMENSION); + Bitmap small = BitmapUtil.createScaledBitmap(bitmap, blurSize.x, blurSize.y); + + Log.d(TAG, "Bitmap: " + bitmap.getWidth() + "x" + bitmap.getHeight() + ", Blur: " + blurSize.x + "x" + blurSize.y); + + RenderScript rs = RenderScript.create(context); + Allocation input = Allocation.createFromBitmap(rs, small); + Allocation output = Allocation.createTyped(rs, input.getType()); + ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + + script.setRadius(25f); + script.setInput(input); + script.forEach(output); + + Bitmap blurred = Bitmap.createBitmap(small.getWidth(), small.getHeight(), small.getConfig()); + output.copyTo(blurred); + return blurred; + } + + private static @NonNull Point scaleKeepingAspectRatio(@NonNull Point dimens, int maxDimen) { + int outX = dimens.x; + int outY = dimens.y; + + if (dimens.x > maxDimen || dimens.y > maxDimen) { + outX = maxDimen; + outY = maxDimen; + + float widthRatio = dimens.x / (float) maxDimen; + float heightRatio = dimens.y / (float) maxDimen; + + if (widthRatio > heightRatio) { + outY = (int) (dimens.y / widthRatio); + } else { + outX = (int) (dimens.x / heightRatio); + } + } + + return new Point(outX, outY); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriGlideRenderer createFromParcel(Parcel in) { + return new UriGlideRenderer(Uri.parse(in.readString()), + in.readInt() == 1, + in.readInt(), + in.readInt() + ); + } + + @Override + public UriGlideRenderer[] newArray(int size) { + return new UriGlideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(imageUri!=null? imageUri.toString() : ""); + dest.writeInt(decryptable ? 1 : 0); + dest.writeInt(maxWidth); + dest.writeInt(maxHeight); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java b/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..996b4d3f2937d84121d787a0fc90e4090e92f8b0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.scribbles.widget; + +import android.graphics.PorterDuff; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class ColorPaletteAdapter extends RecyclerView.Adapter { + + private final List colors = new ArrayList<>(); + + private EventListener eventListener; + + @Override + public @NonNull ColorViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ColorViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_color, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ColorViewHolder holder, int position) { + holder.bind(colors.get(position), eventListener); + } + + @Override + public int getItemCount() { + return colors.size(); + } + + public void setColors(@NonNull Collection colors) { + this.colors.clear(); + this.colors.addAll(colors); + + notifyDataSetChanged(); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + + notifyDataSetChanged(); + } + + public interface EventListener { + void onColorSelected(int color); + } + + static class ColorViewHolder extends RecyclerView.ViewHolder { + + final ImageView foreground; + + ColorViewHolder(View itemView) { + super(itemView); + foreground = itemView.findViewById(R.id.palette_item_foreground); + } + + void bind(int color, @Nullable EventListener eventListener) { + foreground.setColorFilter(color, PorterDuff.Mode.SRC_IN); + + if (eventListener != null) { + itemView.setOnClickListener(v -> eventListener.onColorSelected(color)); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java b/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java new file mode 100644 index 0000000000000000000000000000000000000000..e9ca45e01986e65625578ae0f20c94e7c149db6f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2016 Mark Charles + * Copyright (c) 2016 Open Whisper Systems + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.thoughtcrime.securesms.scribbles.widget; + + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Shader; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import org.thoughtcrime.securesms.R; + +public class VerticalSlideColorPicker extends View { + + private static final float INDICATOR_TO_BAR_WIDTH_RATIO = 0.5f; + + private Paint paint; + private Paint strokePaint; + private Paint indicatorStrokePaint; + private Paint indicatorFillPaint; + private Path path; + private Bitmap bitmap; + private Canvas bitmapCanvas; + + private int viewWidth; + private int viewHeight; + private int centerX; + private float colorPickerRadius; + private RectF colorPickerBody; + + private OnColorChangeListener onColorChangeListener; + + private int borderColor; + private float borderWidth; + private float indicatorRadius; + private int[] colors; + + private int touchY; + private int activeColor; + + public VerticalSlideColorPicker(Context context) { + super(context); + init(); + } + + public VerticalSlideColorPicker(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.VerticalSlideColorPicker, 0, 0); + + try { + int colorsResourceId = a.getResourceId(R.styleable.VerticalSlideColorPicker_pickerColors, R.array.scribble_colors); + + colors = a.getResources().getIntArray(colorsResourceId); + borderColor = a.getColor(R.styleable.VerticalSlideColorPicker_pickerBorderColor, Color.WHITE); + borderWidth = a.getDimension(R.styleable.VerticalSlideColorPicker_pickerBorderWidth, 10f); + + } finally { + a.recycle(); + } + + init(); + } + + public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setWillNotDraw(false); + + paint = new Paint(); + paint.setStyle(Paint.Style.FILL); + paint.setAntiAlias(true); + + path = new Path(); + + strokePaint = new Paint(); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setColor(borderColor); + strokePaint.setAntiAlias(true); + strokePaint.setStrokeWidth(borderWidth); + + indicatorStrokePaint = new Paint(strokePaint); + indicatorStrokePaint.setStrokeWidth(borderWidth / 2); + + indicatorFillPaint = new Paint(); + indicatorFillPaint.setStyle(Paint.Style.FILL); + indicatorFillPaint.setAntiAlias(true); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + path.addCircle(centerX, borderWidth + colorPickerRadius + indicatorRadius, colorPickerRadius, Path.Direction.CW); + path.addRect(colorPickerBody, Path.Direction.CW); + path.addCircle(centerX, viewHeight - (borderWidth + colorPickerRadius + indicatorRadius), colorPickerRadius, Path.Direction.CW); + + bitmapCanvas.drawColor(Color.TRANSPARENT); + + bitmapCanvas.drawPath(path, strokePaint); + bitmapCanvas.drawPath(path, paint); + + canvas.drawBitmap(bitmap, 0, 0, null); + + touchY = Math.max((int) colorPickerBody.top, touchY); + + indicatorFillPaint.setColor(activeColor); + canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorFillPaint); + canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorStrokePaint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + touchY = (int) Math.min(event.getY(), colorPickerBody.bottom); + touchY = (int) Math.max(colorPickerBody.top, touchY); + + activeColor = bitmap.getPixel(viewWidth/2, touchY); + + if (onColorChangeListener != null) { + onColorChangeListener.onColorChange(activeColor); + } + + invalidate(); + + return true; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + viewWidth = w; + viewHeight = h; + + if (viewWidth <= 0 || viewHeight <= 0) return; + + int barWidth = (int) (viewWidth * INDICATOR_TO_BAR_WIDTH_RATIO); + + centerX = viewWidth / 2; + indicatorRadius = (viewWidth / 2) - borderWidth; + colorPickerRadius = (barWidth / 2) - borderWidth; + + colorPickerBody = new RectF(centerX - colorPickerRadius, + borderWidth + colorPickerRadius + indicatorRadius, + centerX + colorPickerRadius, + viewHeight - (borderWidth + colorPickerRadius + indicatorRadius)); + + LinearGradient gradient = new LinearGradient(0, colorPickerBody.top, 0, colorPickerBody.bottom, colors, null, Shader.TileMode.CLAMP); + paint.setShader(gradient); + + if (bitmap != null) { + bitmap.recycle(); + } + + bitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888); + bitmapCanvas = new Canvas(bitmap); + } + + public void setBorderColor(int borderColor) { + this.borderColor = borderColor; + invalidate(); + } + + public void setBorderWidth(float borderWidth) { + this.borderWidth = borderWidth; + invalidate(); + } + + public void setColors(int[] colors) { + this.colors = colors; + invalidate(); + } + + public void setActiveColor(int color) { + activeColor = color; + + if (colorPickerBody != null) { + touchY = (int) colorPickerBody.top; + } + + if (onColorChangeListener != null) { + onColorChangeListener.onColorChange(color); + } + + invalidate(); + } + + public int getActiveColor() { + return activeColor; + } + + public void setOnColorChangeListener(OnColorChangeListener onColorChangeListener) { + this.onColorChangeListener = onColorChangeListener; + } + + public interface OnColorChangeListener { + void onColorChange(int selectedColor); + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java b/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b6a2b0c5a3fced3fe5516799c0d7a1e077e44fa8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java @@ -0,0 +1,238 @@ +package org.thoughtcrime.securesms.search; + + +import static org.thoughtcrime.securesms.util.ShareUtil.isRelayingMessageContent; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.BaseConversationListAdapter; +import org.thoughtcrime.securesms.BaseConversationListFragment; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.search.model.SearchResult; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; + +import java.util.Set; + +/** + * A fragment that is displayed to do full-text search of messages, groups, and contacts. + */ +public class SearchFragment extends BaseConversationListFragment + implements SearchListAdapter.EventListener, DcEventCenter.DcEventDelegate { + + public static final String TAG = "SearchFragment"; + + private TextView noResultsView; + private StickyHeaderDecoration listDecoration; + + private SearchViewModel viewModel; + private SearchListAdapter listAdapter; + private String pendingQuery; + + public static SearchFragment newInstance() { + Bundle args = new Bundle(); + + SearchFragment fragment = new SearchFragment(); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + viewModel = ViewModelProviders.of(this, (ViewModelProvider.Factory) new SearchViewModel.Factory(requireContext())).get(SearchViewModel.class); + DcEventCenter eventCenter = DcHelper.getEventCenter(requireContext()); + eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); + eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSGS_NOTICED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_DELIVERED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_FAILED, this); + eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this); + + if (pendingQuery != null) { + viewModel.updateQuery(pendingQuery); + pendingQuery = null; + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_search, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + noResultsView = view.findViewById(R.id.search_no_results); + RecyclerView listView = view.findViewById(R.id.search_list); + fab = view.findViewById(R.id.fab); + + listAdapter = new SearchListAdapter(getContext(), GlideApp.with(this), this); + listDecoration = new StickyHeaderDecoration(listAdapter, false, true); + + fab.setVisibility(View.GONE); + listView.setAdapter(listAdapter); + listView.addItemDecoration(listDecoration); + listView.setLayoutManager(new LinearLayoutManager(getContext())); + } + + @Override + public void onStart() { + super.onStart(); + viewModel.setForwardingMode(isRelayingMessageContent(getActivity())); + viewModel.getSearchResult().observe(this, result -> { + result = result != null ? result : SearchResult.EMPTY; + + listAdapter.updateResults(result); + listDecoration.invalidateLayouts(); + + if (result.isEmpty()) { + if (TextUtils.isEmpty(viewModel.getLastQuery().trim())) { + noResultsView.setVisibility(View.GONE); + } else { + noResultsView.setVisibility(View.VISIBLE); + noResultsView.setText(getString(R.string.search_no_result_for_x, viewModel.getLastQuery())); + } + } else { + noResultsView.setVisibility(View.VISIBLE); + noResultsView.setText(""); + } + }); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (listDecoration != null) { + listDecoration.onConfigurationChanged(newConfig); + } + } + + @Override + public void onDestroy() { + DcHelper.getEventCenter(requireContext()).removeObservers(this); + super.onDestroy(); + } + + @Override + public void onConversationClicked(@NonNull DcChatlist.Item chatlistItem) { + onItemClick(chatlistItem.chatId); + } + + @Override + public void onConversationLongClicked(@NonNull DcChatlist.Item chatlistItem) { + onItemLongClick(chatlistItem.chatId); + } + + @Override + public void onContactClicked(@NonNull DcContact contact) { + if (actionMode != null) { + return; + } + + ConversationListActivity conversationList = (ConversationListActivity) getActivity(); + if (conversationList != null) { + DcContext dcContext = DcHelper.getContext(requireContext()); + int chatId = dcContext.getChatIdByContactId(contact.getId()); + if(chatId==0) { + new AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.ask_start_chat_with, contact.getDisplayName())) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + int chatId1 = dcContext.createChatByContactId(contact.getId()); + conversationList.onCreateConversation(chatId1); + }).show(); + } + else { + conversationList.onCreateConversation(chatId); + } + } + } + + @Override + public void onMessageClicked(@NonNull DcMsg message) { + if (actionMode != null) { + return; + } + + ConversationListActivity conversationList = (ConversationListActivity) getActivity(); + if (conversationList != null) { + DcContext dcContext = DcHelper.getContext(requireContext()); + int chatId = message.getChatId(); + int startingPosition = DcMsg.getMessagePosition(message, dcContext); + conversationList.openConversation(chatId, startingPosition); + } + } + + public void updateSearchQuery(@NonNull String query) { + if (viewModel != null) { + viewModel.updateQuery(query); + } else { + pendingQuery = query; + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + if (viewModel != null) { + viewModel.updateQuery(); + } + } + + @Override + protected boolean offerToArchive() { + DcContext dcContext = DcHelper.getContext(requireActivity()); + final Set selectedChats = listAdapter.getBatchSelections(); + for (long chatId : selectedChats) { + DcChat dcChat = dcContext.getChat((int)chatId); + if (dcChat.getVisibility() != DcChat.DC_CHAT_VISIBILITY_ARCHIVED) { + return true; + } + } + return false; + } + + @Override + protected void setFabVisibility(boolean isActionMode) { + if (isActionMode && isRelayingMessageContent(getActivity())) { + fab.setVisibility(View.VISIBLE); + } else { + fab.setVisibility(View.GONE); + } + } + + @Override + protected BaseConversationListAdapter getListAdapter() { + return listAdapter; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java b/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..db334b7cc9e2e2e8d388e7c272c65332a6261185 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java @@ -0,0 +1,258 @@ +package org.thoughtcrime.securesms.search; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.BaseConversationListAdapter; +import org.thoughtcrime.securesms.ConversationListItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.search.model.SearchResult; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; + +import java.util.Set; + +class SearchListAdapter extends BaseConversationListAdapter + implements StickyHeaderDecoration.StickyHeaderAdapter +{ + private static final int TYPE_CHATS = 1; + private static final int TYPE_CONTACTS = 2; + private static final int TYPE_MESSAGES = 3; + + private final GlideRequests glideRequests; + private final EventListener eventListener; + + @NonNull + private SearchResult searchResult = SearchResult.EMPTY; + + final Context context; + final DcContext dcContext; // reset on account switching is not needed because SearchFragment and SearchListAdapter are recreated in every search start + + SearchListAdapter(Context context, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener) + { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.context = context; + this.dcContext = DcHelper.getContext(context); + } + + @NonNull + @Override + public SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new SearchResultViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.conversation_list_item_view, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) { + DcChatlist.Item conversationResult = getConversationResult(position); + + if (conversationResult != null) { + holder.bind(context, conversationResult, glideRequests, eventListener, batchSet, batchMode, searchResult.getQuery()); + return; + } + + DcContact contactResult = getContactResult(position); + + if (contactResult != null) { + holder.bind(contactResult, glideRequests, eventListener, searchResult.getQuery()); + return; + } + + DcMsg messageResult = getMessageResult(position); + + if (messageResult != null) { + holder.bind(messageResult, glideRequests, eventListener, searchResult.getQuery()); + } + } + + @Override + public void onViewRecycled(SearchResultViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return searchResult.size(); + } + + @Override + public long getHeaderId(int position) { + if (getConversationResult(position) != null) { + return TYPE_CHATS; + } else if (getContactResult(position) != null) { + return TYPE_CONTACTS; + } else { + return TYPE_MESSAGES; + } + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.contact_selection_list_divider, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { + int headerType = (int)getHeaderId(position); + int textId = R.plurals.n_messages; + int count = 1; + boolean maybeLimitedTo1000 = false; + + switch (headerType) { + case TYPE_CHATS: + textId = R.plurals.n_chats; + count = searchResult.getChats().getCnt(); + break; + case TYPE_CONTACTS: + textId = R.plurals.n_contacts; + count = searchResult.getContacts().length; + break; + case TYPE_MESSAGES: + textId = R.plurals.n_messages; + count = searchResult.getMessages().length; + maybeLimitedTo1000 = count==1000; // a count of 1000 results may be limited, see documentation of dc_search_msgs() + break; + } + + String title = context.getResources().getQuantityString(textId, count, count); + if (maybeLimitedTo1000) { + title = title.replace("000", "000+"); // skipping the first digit allows formattings as "1.000" or "1,000" + } + viewHolder.bind(title); + } + + void updateResults(@NonNull SearchResult result) { + this.searchResult = result; + notifyDataSetChanged(); + } + + @Override + public void selectAllThreads() { + for (int i = 0; i < searchResult.getChats().getCnt(); i++) { + batchSet.add((long)searchResult.getChats().getItem(i).chatId); + } + notifyDataSetChanged(); + } + + @Nullable + private DcChatlist.Item getConversationResult(int position) { + if (position < searchResult.getChats().getCnt()) { + return searchResult.getChats().getItem(position); + } + return null; + } + + @Nullable + private DcContact getContactResult(int position) { + if (position >= getFirstContactIndex() && position < getFirstMessageIndex()) { + return dcContext.getContact(searchResult.getContacts()[position - getFirstContactIndex()]); + } + return null; + } + + @Nullable + private DcMsg getMessageResult(int position) { + if (position >= getFirstMessageIndex() && position < searchResult.size()) { + return dcContext.getMsg(searchResult.getMessages()[position - getFirstMessageIndex()]); + } + return null; + } + + private int getFirstContactIndex() { + return searchResult.getChats().getCnt(); + } + + private int getFirstMessageIndex() { + return getFirstContactIndex() + searchResult.getContacts().length; + } + + public interface EventListener { + void onConversationClicked(@NonNull DcChatlist.Item chatlistItem); + void onConversationLongClicked(@NonNull DcChatlist.Item chatlistItem); + void onContactClicked(@NonNull DcContact contact); + void onMessageClicked(@NonNull DcMsg message); + } + + static class SearchResultViewHolder extends RecyclerView.ViewHolder { + + private final ConversationListItem root; + + SearchResultViewHolder(View itemView) { + super(itemView); + root = (ConversationListItem) itemView; + } + + void bind(Context context, + @NonNull DcChatlist.Item chatlistItem, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull Set selectedThreads, + boolean batchMode, + @Nullable String query) + { + DcContext dcContext = DcHelper.getContext(context); + ThreadRecord threadRecord = DcHelper.getThreadRecord(context, chatlistItem.summary, dcContext.getChat(chatlistItem.chatId)); + root.bind(threadRecord, chatlistItem.msgId, chatlistItem.summary, glideRequests, selectedThreads, batchMode, query); + root.setOnClickListener(view -> eventListener.onConversationClicked(chatlistItem)); + root.setOnLongClickListener(view -> { + eventListener.onConversationLongClicked(chatlistItem); + return true; + }); + } + + void bind(@NonNull DcContact contactResult, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @Nullable String query) + { + root.bind(contactResult, glideRequests, query); + root.setOnClickListener(view -> eventListener.onContactClicked(contactResult)); + } + + void bind(@NonNull DcMsg messageResult, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @Nullable String query) + { + root.bind(messageResult, glideRequests, query); + root.setOnClickListener(view -> eventListener.onMessageClicked(messageResult)); + } + + void recycle() { + root.unbind(); + root.setOnClickListener(null); + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + + private final TextView titleView; + + public HeaderViewHolder(View itemView) { + super(itemView); + titleView = itemView.findViewById(R.id.label); + } + + public void bind(String text) { + titleView.setText(text); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java b/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..413a60c2d5be63d9da11172bf16eeba7a5d24777 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.search; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.b44t.messenger.DcChatlist; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.search.model.SearchResult; +import org.thoughtcrime.securesms.util.Util; + +class SearchViewModel extends ViewModel { + private static final String TAG = SearchViewModel.class.getSimpleName(); + private final ObservingLiveData searchResult; + private String lastQuery; + private final DcContext dcContext; + private boolean forwarding = false; + private boolean inBgSearch; + private boolean needsAnotherBgSearch; + + SearchViewModel(@NonNull Context context) { + this.dcContext = DcHelper.getContext(context.getApplicationContext()); + this.searchResult = new ObservingLiveData(); + } + + LiveData getSearchResult() { + return searchResult; + } + + public void setForwardingMode(boolean forwarding) { + this.forwarding = forwarding; + } + + + void updateQuery(String query) { + lastQuery = query; + updateQuery(); + } + + public void updateQuery() { + if (inBgSearch) { + needsAnotherBgSearch = true; + Log.i(TAG, "... search call debounced"); + } else { + inBgSearch = true; + Util.runOnBackground(() -> { + + Util.sleep(100); + needsAnotherBgSearch = false; + queryAndCallback(lastQuery, searchResult::postValue); + + while (needsAnotherBgSearch) { + Util.sleep(100); + needsAnotherBgSearch = false; + Log.i(TAG, "... executing debounced search call"); + queryAndCallback(lastQuery, searchResult::postValue); + } + + inBgSearch = false; + }); + } + } + + private void queryAndCallback(@NonNull String query, @NonNull SearchViewModel.Callback callback) { + int overallCnt = 0; + + if (TextUtils.isEmpty(query)) { + callback.onResult(SearchResult.EMPTY); + return; + } + + // #1 search for chats + long startMs = System.currentTimeMillis(); + DcChatlist conversations = dcContext.getChatlist(forwarding? DcContext.DC_GCL_FOR_FORWARDING : 0, query, 0); + overallCnt += conversations.getCnt(); + Log.i(TAG, "⏰ getChatlist(" + query + "): " + (System.currentTimeMillis() - startMs) + "ms"); + + // #2 search for contacts + if (!query.equals(lastQuery) && overallCnt > 0) { + Log.i(TAG, "... skipping getContacts() and searchMsgs(), more recent search pending"); + callback.onResult(new SearchResult(query, new int[0], conversations, new int[0])); + return; + } + + startMs = System.currentTimeMillis(); + int[] contacts = dcContext.getContacts(DcContext.DC_GCL_ADD_SELF, query); + overallCnt += contacts.length; + Log.i(TAG, "⏰ getContacts(" + query + "): " + (System.currentTimeMillis() - startMs) + "ms"); + + // #3 search for messages + if (forwarding) { + Log.i(TAG, "... searchMsgs() disabled by caller"); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + return; + } + + if (query.length() <= 1) { + Log.i(TAG, "... skipping searchMsgs(), string too short"); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + return; + } + + if (!query.equals(lastQuery) && overallCnt > 0) { + Log.i(TAG, "... skipping searchMsgs(), more recent search pending"); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + return; + } + + startMs = System.currentTimeMillis(); + int[] messages = dcContext.searchMsgs(0, query); + Log.i(TAG, "⏰ searchMsgs(" + query + "): " + (System.currentTimeMillis() - startMs) + "ms"); + + callback.onResult(new SearchResult(query, contacts, conversations, messages)); + } + + @NonNull + String getLastQuery() { + return lastQuery == null ? "" : lastQuery; + } + + @Override + protected void onCleared() { + } + + private static class ObservingLiveData extends MutableLiveData { + } + + public static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final Context context; + + public Factory(@NonNull Context context) { + this.context = context; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + return modelClass.cast(new SearchViewModel(context)); + } + } + + public interface Callback { + void onResult(@NonNull SearchResult result); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java b/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java new file mode 100644 index 0000000000000000000000000000000000000000..5711179e41672a1adb3505dd70280641c5ca955a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.search.model; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcChatlist; + +/** + * Represents an all-encompassing search result that can contain various result for different + * subcategories. + */ +public class SearchResult { + + public static final SearchResult EMPTY = new SearchResult("", new int[]{}, new DcChatlist(0, 0), new int[]{}); + + private final String query; + private final int[] contacts; + private final DcChatlist conversations; + private final int[] messages; + + public SearchResult(@NonNull String query, + @NonNull int[] contacts, + @NonNull DcChatlist conversations, + @NonNull int[] messages) + { + this.query = query; + this.contacts = contacts; + this.conversations = conversations; + this.messages = messages; + } + + public int[] getContacts() { + return contacts; + } + + public DcChatlist getChats() { + return conversations; + } + + public int[] getMessages() { + return messages; + } + + public String getQuery() { + return query; + } + + public int size() { + return contacts.length + conversations.getCnt() + messages.length; + } + + public boolean isEmpty() { + return size() == 0; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java b/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..3fcc8be61a69898316844ae6f2573fb17e2ac5b4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class BootReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + Log.i("DeltaChat", "*** BootReceiver.onReceive()"); + // there's nothing more to do here as all initialisation stuff is already done in + // on program startup which is done before this broadcast is sent. + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/service/FetchForegroundService.java b/src/main/java/org/thoughtcrime/securesms/service/FetchForegroundService.java new file mode 100644 index 0000000000000000000000000000000000000000..29b71c2c7fa18b419643ea163f296a52996289a5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/FetchForegroundService.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.ForegroundDetector; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.notifications.NotificationCenter; +import org.thoughtcrime.securesms.util.Util; + +public final class FetchForegroundService extends Service { + private static final String TAG = FcmReceiveService.class.getSimpleName(); + private static final Object SERVICE_LOCK = new Object(); + private static final Object STOP_NOTIFIER = new Object(); + private static volatile boolean fetchingSynchronously = false; + private static Intent service; + + public static void start(Context context) { + ForegroundDetector foregroundDetector = ForegroundDetector.getInstance(); + if (foregroundDetector != null && foregroundDetector.isForeground()) { + return; + } + + GenericForegroundService.createFgNotificationChannel(context); + try { + synchronized (SERVICE_LOCK) { + if (service == null) { + service = new Intent(context, FetchForegroundService.class); + ContextCompat.startForegroundService(context, service); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to start foreground service: " + e + ", fetching in background."); + // According to the documentation https://firebase.google.com/docs/cloud-messaging/android/receive, + // we need to handle the message within 20s, and the time window may be even shorter than 20s, + // so, use 10s to be safe. + fetchingSynchronously = true; + if (ApplicationContext.dcAccounts.backgroundFetch(10)) { + // The background fetch was successful, but we need to wait until all events were processed. + // After all events were processed, we will get DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE, + // and stop() will be called. + synchronized (STOP_NOTIFIER) { + while (fetchingSynchronously) { + try { + // The `wait()` needs to be enclosed in a while loop because there may be + // "spurious wake-ups", i.e. `wait()` may return even though `notifyAll()` wasn't called. + STOP_NOTIFIER.wait(); + } catch (InterruptedException ex) {} + } + } + } + } + } + + public static void stop(Context context) { + if (fetchingSynchronously) { + fetchingSynchronously = false; + synchronized (STOP_NOTIFIER) { + STOP_NOTIFIER.notifyAll(); + } + } + + synchronized (SERVICE_LOCK) { + if (service != null) { + context.stopService(service); + service = null; + } + } + } + + @Override + public void onCreate() { + Log.i(TAG, "Creating fetch service"); + super.onCreate(); + + Notification notification = new NotificationCompat.Builder(this, NotificationCenter.CH_GENERIC) + .setContentTitle(getString(R.string.connectivity_updating)) + .setSmallIcon(R.drawable.notification_permanent) + .build(); + + startForeground(NotificationCenter.ID_FETCH, notification); + + Util.runOnAnyBackgroundThread(() -> { + Log.i(TAG, "Starting fetch"); + if (!ApplicationContext.dcAccounts.backgroundFetch(300)) { // as startForeground() was called, there is time + FetchForegroundService.stop(this); + } // else we stop FetchForegroundService on DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE + }); + } + + @Override + public void onDestroy() { + stopForeground(true); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onTimeout(int startId, int fgsType) { + ApplicationContext.dcAccounts.stopBackgroundFetch(); + stopSelf(); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java b/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java new file mode 100644 index 0000000000000000000000000000000000000000..56915339410235d07f777e76c8bbab60318e75d5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java @@ -0,0 +1,287 @@ +package org.thoughtcrime.securesms.service; + +import android.annotation.TargetApi; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat.Builder; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.DummyActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.notifications.NotificationCenter; +import org.thoughtcrime.securesms.util.IntentUtils; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public final class GenericForegroundService extends Service { + + private static final String TAG = GenericForegroundService.class.getSimpleName(); + + private final IBinder binder = new LocalBinder(); + + private static final String EXTRA_TITLE = "extra_title"; + private static final String EXTRA_CONTENT_TEXT = "extra_content_text"; + private static final String EXTRA_CHANNEL_ID = "extra_channel_id"; + private static final String EXTRA_ICON_RES = "extra_icon_res"; + private static final String EXTRA_ID = "extra_id"; + private static final String EXTRA_PROGRESS = "extra_progress"; + private static final String EXTRA_PROGRESS_MAX = "extra_progress_max"; + private static final String EXTRA_PROGRESS_INDETERMINATE = "extra_progress_indeterminate"; + + private static final String ACTION_START = "start"; + private static final String ACTION_STOP = "stop"; + + private static final AtomicInteger NEXT_ID = new AtomicInteger(); + private static final AtomicBoolean CHANNEL_CREATED = new AtomicBoolean(false); + + private static int startedCounter = 0; + + private final LinkedHashMap allActiveMessages = new LinkedHashMap<>(); + + private static final Entry DEFAULTS = new Entry("", "", NotificationCenter.CH_GENERIC, R.drawable.icon_notification, -1, 0, 0, false); + + private @Nullable Entry lastPosted; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + throw new IllegalStateException("Intent needs to be non-null."); + } + + synchronized (GenericForegroundService.class) { + String action = intent.getAction(); + if (ACTION_START.equals(action)) handleStart(intent); + else if (ACTION_STOP .equals(action)) handleStop(intent); + else throw new IllegalStateException(String.format("Action needs to be %s or %s.", ACTION_START, ACTION_STOP)); + + updateNotification(); + + return START_NOT_STICKY; + } + } + + private synchronized void updateNotification() { + Iterator iterator = allActiveMessages.values().iterator(); + + if (iterator.hasNext()) { + postObligatoryForegroundNotification(iterator.next()); + } else { + Log.i(TAG, "Last request. Ending foreground service."); + postObligatoryForegroundNotification(lastPosted != null ? lastPosted : DEFAULTS); + stopForeground(true); + stopSelf(); + } + } + + + private synchronized void handleStart(@NonNull Intent intent) { + Entry entry = Entry.fromIntent(intent); + + Log.i(TAG, String.format(Locale.ENGLISH, "handleStart() %s", entry)); + + allActiveMessages.put(entry.id, entry); + } + + private synchronized void handleStop(@NonNull Intent intent) { + Log.i(TAG, "handleStop()"); + + int id = intent.getIntExtra(EXTRA_ID, -1); + + Entry removed = allActiveMessages.remove(id); + + if (removed == null) { + Log.w(TAG, "Could not find entry to remove"); + } + } + + private void postObligatoryForegroundNotification(@NonNull Entry active) { + lastPosted = active; + startForeground(NotificationCenter.ID_GENERIC, new Builder(this, active.channelId) + .setSmallIcon(active.iconRes) + .setContentTitle(active.title) + .setTicker(active.contentText) + .setContentText(active.contentText) + .setProgress(active.progressMax, active.progress, active.indeterminate) + .setContentIntent(PendingIntent.getActivity(this, 0, new Intent(this, DummyActivity.class), IntentUtils.FLAG_MUTABLE())) + .build()); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + + public static NotificationController startForegroundTask(@NonNull Context context, @NonNull String task) { + startedCounter++; + final int id = NEXT_ID.getAndIncrement(); + + createFgNotificationChannel(context); + Intent intent = new Intent(context, GenericForegroundService.class); + intent.setAction(ACTION_START); + intent.putExtra(EXTRA_TITLE, task); + intent.putExtra(EXTRA_CHANNEL_ID, NotificationCenter.CH_GENERIC); + intent.putExtra(EXTRA_ICON_RES, R.drawable.notification_permanent); + intent.putExtra(EXTRA_ID, id); + + ContextCompat.startForegroundService(context, intent); + + return new NotificationController(context, id); + } + + public static void stopForegroundTask(@NonNull Context context, int id) { + Intent intent = new Intent(context, GenericForegroundService.class); + intent.setAction(ACTION_STOP); + intent.putExtra(EXTRA_ID, id); + + ContextCompat.startForegroundService(context, intent); + startedCounter = Math.max(startedCounter-1, 0); + } + + public static boolean isForegroundTaskStarted() { + return startedCounter > 0; + } + + synchronized void replaceProgress(int id, int progressMax, int progress, boolean indeterminate, String message) { + Entry oldEntry = allActiveMessages.get(id); + + if (oldEntry == null) { + Log.w(TAG, "Failed to replace notification, it was not found"); + return; + } + + if (message == null) { + message = oldEntry.contentText; + } + + Entry newEntry = new Entry(oldEntry.title, message, oldEntry.channelId, oldEntry.iconRes, oldEntry.id, progressMax, progress, indeterminate); + + if (oldEntry.equals(newEntry)) { + Log.d(TAG, String.format("handleReplace() skip, no change %s", newEntry)); + return; + } + + Log.i(TAG, String.format("handleReplace() %s", newEntry)); + + allActiveMessages.put(newEntry.id, newEntry); + + updateNotification(); + } + + @TargetApi(Build.VERSION_CODES.O) + static public void createFgNotificationChannel(Context context) { + if(!CHANNEL_CREATED.get() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CHANNEL_CREATED.set(true); + NotificationChannel channel = new NotificationChannel(NotificationCenter.CH_GENERIC, + "Generic Background Service", NotificationManager.IMPORTANCE_MIN); + channel.setDescription("Ensure app will not be killed while long ongoing background tasks are running."); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + + private static class Entry { + final @NonNull String title; + final @NonNull String contentText; + final @NonNull String channelId; + final int id; + final @DrawableRes int iconRes; + final int progress; + final int progressMax; + final boolean indeterminate; + + private Entry(@NonNull String title, @NonNull String contentText, @NonNull String channelId, @DrawableRes int iconRes, int id, int progressMax, int progress, boolean indeterminate) { + this.title = title; + this.contentText = contentText; + this.channelId = channelId; + this.iconRes = iconRes; + this.id = id; + this.progress = progress; + this.progressMax = progressMax; + this.indeterminate = indeterminate; + } + + private static Entry fromIntent(@NonNull Intent intent) { + int id = intent.getIntExtra(EXTRA_ID, DEFAULTS.id); + + String title = intent.getStringExtra(EXTRA_TITLE); + if (title == null) title = DEFAULTS.title; + + String contentText = intent.getStringExtra(EXTRA_CONTENT_TEXT); + if (contentText == null) contentText = DEFAULTS.contentText; + + String channelId = intent.getStringExtra(EXTRA_CHANNEL_ID); + if (channelId == null) channelId = DEFAULTS.channelId; + + int iconRes = intent.getIntExtra(EXTRA_ICON_RES, DEFAULTS.iconRes); + int progress = intent.getIntExtra(EXTRA_PROGRESS, DEFAULTS.progress); + int progressMax = intent.getIntExtra(EXTRA_PROGRESS_MAX, DEFAULTS.progressMax); + boolean indeterminate = intent.getBooleanExtra(EXTRA_PROGRESS_INDETERMINATE, DEFAULTS.indeterminate); + + return new Entry(title, contentText, channelId, iconRes, id, progressMax, progress, indeterminate); + } + + @Override + public @NonNull String toString() { + return String.format(Locale.ENGLISH, "ChannelId: %s Id: %d Progress: %d/%d %s", channelId, id, progress, progressMax, indeterminate ? "indeterminate" : "determinate"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Entry entry = (Entry) o; + return id == entry.id && + iconRes == entry.iconRes && + progress == entry.progress && + progressMax == entry.progressMax && + indeterminate == entry.indeterminate && + title.equals(entry.title) && + contentText.equals(entry.contentText) && + channelId.equals(entry.channelId); + } + + @Override + public int hashCode() { + int hashCode = title.hashCode(); + hashCode *= 31; + hashCode += channelId.hashCode(); + hashCode *= 31; + hashCode += id; + hashCode *= 31; + hashCode += iconRes; + hashCode *= 31; + hashCode += progress; + hashCode *= 31; + hashCode += progressMax; + hashCode *= 31; + hashCode += indeterminate ? 1 : 0; + return hashCode; + } + } + + class LocalBinder extends Binder { + GenericForegroundService getService() { + // Return this instance of LocalService so clients can call public methods + return GenericForegroundService.this; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java b/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java new file mode 100644 index 0000000000000000000000000000000000000000..b45c8fc494a772a134cd2b4a3e651205d96d50a1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +import androidx.annotation.NonNull; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Class to control notifications triggered by GenericForeGroundService. + * + */ +public final class NotificationController { + + private final @NonNull Context context; + private final int id; + + private int progress; + private int progressMax; + private boolean indeterminate; + private String message = ""; + private long percent = -1; + + private final ServiceConnection serviceConnection; + + private final AtomicReference service = new AtomicReference<>(); + + NotificationController(@NonNull Context context, int id) { + this.context = context; + this.id = id; + + serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + GenericForegroundService.LocalBinder binder = (GenericForegroundService.LocalBinder) service; + GenericForegroundService genericForegroundService = binder.getService(); + + NotificationController.this.service.set(genericForegroundService); + + updateProgressOnService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service.set(null); + } + }; + + context.bindService(new Intent(context, GenericForegroundService.class), serviceConnection, Context.BIND_AUTO_CREATE); + } + + public int getId() { + return id; + } + + public void close() { + try { + GenericForegroundService.stopForegroundTask(context, id); + context.unbindService(serviceConnection); + } catch(Exception e) { + e.printStackTrace(); + } + } + + public void setIndeterminateProgress() { + setProgress(0, 0, true, message); + } + + public void setProgress(long newProgressMax, long newProgress, @NonNull String newMessage) { + setProgress((int) newProgressMax, (int) newProgress, false, newMessage); + } + + private synchronized void setProgress(int newProgressMax, int newProgress, boolean indeterminant, @NonNull String newMessage) { + int newPercent = newProgressMax != 0 ? 100 * newProgress / newProgressMax : -1; + + boolean same = newPercent == percent && indeterminate == indeterminant && newMessage.equals(message); + + percent = newPercent; + progress = newProgress; + progressMax = newProgressMax; + indeterminate = indeterminant; + message = newMessage; + + if (same) return; + + updateProgressOnService(); + } + + private synchronized void updateProgressOnService() { + GenericForegroundService genericForegroundService = service.get(); + + if (genericForegroundService == null) return; + + genericForegroundService.replaceProgress(id, progressMax, progress, indeterminate, message); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java b/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java new file mode 100644 index 0000000000000000000000000000000000000000..fe7412a5f66f9f7bbcbb0acc632982fd135ce5ea --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.util.Prefs; + +/** + * Respond to a PanicKit trigger Intent by locking the app. PanicKit provides a + * common framework for creating "panic button" apps that can trigger actions + * in "panic responder" apps. In this case, the response is to lock the app, + * if it has been configured to do so via the Signal lock preference. If the + * user has not set a passphrase, then the panic trigger intent does nothing. + */ +public class PanicResponderListener extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && !Prefs.isPasswordDisabled(context) && + "info.guardianproject.panic.action.TRIGGER".equals(intent.getAction())) + { + // as delta is protected with the system credentials, + // the current suggestion on "panic" would probably just be to lock the device. + // this would also lock delta chat. + // however, we leave this class to allow easy changes on this. + } + } +} \ No newline at end of file diff --git a/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java b/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..266b3cb87f761e00f39fd1e9b1823652aa696680 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.provider.Settings; +import android.util.Log; + +public final class AccessibilityUtil { + + private AccessibilityUtil() { + } + + public static boolean areAnimationsDisabled(Context context) { + if (context == null) { + Log.e("AccessibilityUtil", "animationsDisabled: context was null"); + return false; + } + return Settings.Global.getFloat(context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1) == 0f; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/AndroidSignalProtocolLogger.java b/src/main/java/org/thoughtcrime/securesms/util/AndroidSignalProtocolLogger.java new file mode 100644 index 0000000000000000000000000000000000000000..4e4c56f67507372ffdc3abaaafefb0d30bfb17c9 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/AndroidSignalProtocolLogger.java @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.thoughtcrime.securesms.util; + +import android.util.Log; +import android.util.SparseIntArray; + +public class AndroidSignalProtocolLogger implements SignalProtocolLogger { + + private static final SparseIntArray PRIORITY_MAP = new SparseIntArray(5) {{ + put(SignalProtocolLogger.INFO, Log.INFO); + put(SignalProtocolLogger.ASSERT, Log.ASSERT); + put(SignalProtocolLogger.DEBUG, Log.DEBUG); + put(SignalProtocolLogger.VERBOSE, Log.VERBOSE); + put(SignalProtocolLogger.WARN, Log.WARN); + + }}; + + @Override + public void log(int priority, String tag, String message) { + int androidPriority = PRIORITY_MAP.get(priority, Log.WARN); + Log.println(androidPriority, tag, message); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java b/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java new file mode 100644 index 0000000000000000000000000000000000000000..1a0f0d2d340b0f276873032ba76c23d4e23564a7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.util; + +/* + * Copyright (C) 2011 Alexander Blom + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.content.Context; + +import androidx.loader.content.AsyncTaskLoader; + +/** + * Loader which extends AsyncTaskLoaders and handles caveats + * as pointed out in http://code.google.com/p/android/issues/detail?id=14944. + * + * Based on CursorLoader.java in the Fragment compatibility package + * + * @author Alexander Blom (me@alexanderblom.se) + * + * @param data type + */ +public abstract class AsyncLoader extends AsyncTaskLoader { + private D data; + + public AsyncLoader(Context context) { + super(context); + } + + @Override + public void deliverResult(D data) { + if (isReset()) { + // An async query came in while the loader is stopped + return; + } + + this.data = data; + + super.deliverResult(data); + } + + + @Override + protected void onStartLoading() { + if (data != null) { + deliverResult(data); + } + + if (takeContentChanged() || data == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + data = null; + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..fd85c17d00798e9a496cae4c96cc6801ed750b84 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.Base64; + +import java.io.ByteArrayOutputStream; + +public class AvatarUtil { + + /** + * convert image path to data URI. + * + * @param filePath File path to image + * @return data URI like "data:image/jpeg;base64,..." + */ + public static String asDataUri(String filePath) { + Bitmap bitmap = BitmapFactory.decodeFile(filePath); + if (bitmap == null) return null; + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos); + byte[] bytes = baos.toByteArray(); + String base64 = Base64.encodeToString(bytes, Base64.NO_WRAP); + return "data:image/jpeg;base64," + base64; + } + + /** + * convert Drawable to data URI. + * + * @param drawable avatar image as Drawable + * @return data URI like "data:image/png;base64,..." + */ + public static String asDataUri(Drawable drawable) { + int w = Math.max(1, drawable.getIntrinsicWidth()); + int h = Math.max(1, drawable.getIntrinsicHeight()); + Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, w, h); + drawable.draw(canvas); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos); + byte[] bytes = baos.toByteArray(); + String base64 = Base64.encodeToString(bytes, Base64.NO_WRAP); + return "data:image/jpeg;base64," + base64; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java b/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java new file mode 100644 index 0000000000000000000000000000000000000000..c106159434d0d5856b57c49c5e0351adbcce92a5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +public class BitmapDecodingException extends Exception { + + public BitmapDecodingException(String s) { + super(s); + } + + public BitmapDecodingException(Exception nested) { + super(nested); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..5eb0570795f986f87ef9c3cce1f355202fd249bd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.Log; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.exifinterface.media.ExifInterface; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; + +public class BitmapUtil { + + private static final String TAG = BitmapUtil.class.getSimpleName(); + + @WorkerThread + public static Bitmap createScaledBitmap(Bitmap bitmap, int maxWidth, int maxHeight) { + if (bitmap.getWidth() <= maxWidth && bitmap.getHeight() <= maxHeight) { + return bitmap; + } + + if (maxWidth <= 0 || maxHeight <= 0) { + return bitmap; + } + + int newWidth = maxWidth; + int newHeight = maxHeight; + + float widthRatio = bitmap.getWidth() / (float) maxWidth; + float heightRatio = bitmap.getHeight() / (float) maxHeight; + + if (widthRatio > heightRatio) { + newHeight = (int) (bitmap.getHeight() / widthRatio); + } else { + newWidth = (int) (bitmap.getWidth() / heightRatio); + } + + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); + } + + private static BitmapFactory.Options getImageDimensions(InputStream inputStream) + throws BitmapDecodingException + { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BufferedInputStream fis = new BufferedInputStream(inputStream); + BitmapFactory.decodeStream(fis, null, options); + try { + fis.close(); + } catch (IOException ioe) { + Log.w(TAG, "failed to close the InputStream after reading image dimensions"); + } + + if (options.outWidth == -1 || options.outHeight == -1) { + throw new BitmapDecodingException("Failed to decode image dimensions: " + options.outWidth + ", " + options.outHeight); + } + + return options; + } + + @Nullable + public static Pair getExifDimensions(InputStream inputStream) throws IOException { + ExifInterface exif = new ExifInterface(inputStream); + int width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0); + int height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0); + if (width == 0 && height == 0) { + return null; + } + + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 || + orientation == ExifInterface.ORIENTATION_TRANSVERSE || + orientation == ExifInterface.ORIENTATION_TRANSPOSE) + { + return new Pair<>(height, width); + } + return new Pair<>(width, height); + } + + public static Pair getDimensions(InputStream inputStream) throws BitmapDecodingException { + BitmapFactory.Options options = getImageDimensions(inputStream); + return new Pair<>(options.outWidth, options.outHeight); + } + + public static byte[] createFromNV21(@NonNull final byte[] data, + final int width, + final int height, + int rotation, + final Rect croppingRect, + final boolean flipHorizontal) + throws IOException + { + byte[] rotated = rotateNV21(data, width, height, rotation, flipHorizontal); + final int rotatedWidth = rotation % 180 > 0 ? height : width; + final int rotatedHeight = rotation % 180 > 0 ? width : height; + YuvImage previewImage = new YuvImage(rotated, ImageFormat.NV21, + rotatedWidth, rotatedHeight, null); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + previewImage.compressToJpeg(croppingRect, 80, outputStream); + byte[] bytes = outputStream.toByteArray(); + outputStream.close(); + return bytes; + } + + /* + * NV21 a.k.a. YUV420sp + * YUV 4:2:0 planar image, with 8 bit Y samples, followed by interleaved V/U plane with 8bit 2x2 + * subsampled chroma samples. + * + * http://www.fourcc.org/yuv.php#NV21 + */ + public static byte[] rotateNV21(@NonNull final byte[] yuv, + final int width, + final int height, + final int rotation, + final boolean flipHorizontal) + throws IOException + { + if (rotation == 0) return yuv; + if (rotation % 90 != 0 || rotation < 0 || rotation > 270) { + throw new IllegalArgumentException("0 <= rotation < 360, rotation % 90 == 0"); + } else if ((width * height * 3) / 2 != yuv.length) { + throw new IOException("provided width and height don't jive with the data length (" + + yuv.length + "). Width: " + width + " height: " + height + + " = data length: " + (width * height * 3) / 2); + } + + final byte[] output = new byte[yuv.length]; + final int frameSize = width * height; + final boolean swap = rotation % 180 != 0; + final boolean xflip = flipHorizontal ? rotation % 270 == 0 : rotation % 270 != 0; + final boolean yflip = rotation >= 180; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + final int yIn = j * width + i; + final int uIn = frameSize + (j >> 1) * width + (i & ~1); + final int vIn = uIn + 1; + + final int wOut = swap ? height : width; + final int hOut = swap ? width : height; + final int iSwapped = swap ? j : i; + final int jSwapped = swap ? i : j; + final int iOut = xflip ? wOut - iSwapped - 1 : iSwapped; + final int jOut = yflip ? hOut - jSwapped - 1 : jSwapped; + + final int yOut = jOut * wOut + iOut; + final int uOut = frameSize + (jOut >> 1) * wOut + (iOut & ~1); + final int vOut = uOut + 1; + + output[yOut] = (byte)(0xff & yuv[yIn]); + output[uOut] = (byte)(0xff & yuv[uIn]); + output[vOut] = (byte)(0xff & yuv[vIn]); + } + } + return output; + } + + public static Bitmap createFromDrawable(final Drawable drawable, final int width, final int height) { + final AtomicBoolean created = new AtomicBoolean(false); + final Bitmap[] result = new Bitmap[1]; + + Runnable runnable = new Runnable() { + @Override + public void run() { + if (drawable instanceof BitmapDrawable) { + result[0] = ((BitmapDrawable) drawable).getBitmap(); + } else { + int canvasWidth = drawable.getIntrinsicWidth(); + if (canvasWidth <= 0) canvasWidth = width; + + int canvasHeight = drawable.getIntrinsicHeight(); + if (canvasHeight <= 0) canvasHeight = height; + + Bitmap bitmap; + + try { + bitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + } catch (Exception e) { + Log.w(TAG, e); + bitmap = null; + } + + result[0] = bitmap; + } + + synchronized (result) { + created.set(true); + result.notifyAll(); + } + } + }; + + Util.runOnMain(runnable); + + synchronized (result) { + while (!created.get()) Util.wait(result, 0); + return result[0]; + } + } + + public static int getMaxTextureSize() { + final int MAX_ALLOWED_TEXTURE_SIZE = 2048; + + EGL10 egl = (EGL10) EGLContext.getEGL(); + EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + + int[] version = new int[2]; + egl.eglInitialize(display, version); + + int[] totalConfigurations = new int[1]; + egl.eglGetConfigs(display, null, 0, totalConfigurations); + + EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]]; + egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations); + + int[] textureSize = new int[1]; + int maximumTextureSize = 0; + + for (int i = 0; i < totalConfigurations[0]; i++) { + egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize); + + if (maximumTextureSize < textureSize[0]) + maximumTextureSize = textureSize[0]; + } + + egl.eglTerminate(display); + + return Math.min(maximumTextureSize, MAX_ALLOWED_TEXTURE_SIZE); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Conversions.java b/src/main/java/org/thoughtcrime/securesms/util/Conversions.java new file mode 100644 index 0000000000000000000000000000000000000000..7fe341111a516d588c391e1e5d0835ce589f107a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Conversions.java @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +public class Conversions { + + public static byte[] longToByteArray(long l) { + byte[] bytes = new byte[8]; + longToByteArray(bytes, 0, l); + return bytes; + } + + public static int longToByteArray(byte[] bytes, int offset, long value) { + bytes[offset + 7] = (byte)value; + bytes[offset + 6] = (byte)(value >> 8); + bytes[offset + 5] = (byte)(value >> 16); + bytes[offset + 4] = (byte)(value >> 24); + bytes[offset + 3] = (byte)(value >> 32); + bytes[offset + 2] = (byte)(value >> 40); + bytes[offset + 1] = (byte)(value >> 48); + bytes[offset] = (byte)(value >> 56); + return 8; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..0a99bf7e17e45e2e175cc6b15ea45202a7afcd30 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.text.format.DateFormat; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * Utility methods to help display dates in a nice, easily readable way. + */ +public class DateUtils extends android.text.format.DateUtils { + + private static boolean isWithin(final long millis, final long span, final TimeUnit unit) { + return System.currentTimeMillis() - millis <= unit.toMillis(span); + } + + private static boolean isYesterday(final long when) { + return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); + } + + private static int convertDelta(final long millis, TimeUnit to) { + return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); + } + + private static String getFormattedDateTime(long time, String template) { + final String localizedPattern = getLocalizedPattern(template); + String ret = new SimpleDateFormat(localizedPattern).format(new Date(time)); + + // having ".," in very common and known abbreviates as weekdays or month names is not needed, + // looks ugly and makes the string longer than needed + ret = ret.replace(".,", ","); + + return ret; + } + + public static String getBriefRelativeTimeSpanString(final Context c, final long timestamp) { + if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + return c.getString(R.string.now); + } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { + int mins = convertDelta(timestamp, TimeUnit.MINUTES); + return c.getResources().getQuantityString(R.plurals.n_minutes, mins, mins); + } else if (isWithin(timestamp, 1, TimeUnit.DAYS)) { + int hours = convertDelta(timestamp, TimeUnit.HOURS); + return c.getResources().getQuantityString(R.plurals.n_hours, hours, hours); + } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { + return getFormattedDateTime(timestamp, "EEE"); + } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { + return getFormattedDateTime(timestamp, "MMM d"); + } else { + return getFormattedDateTime(timestamp, "MMM d, yyyy"); + } + } + + public static String getExtendedTimeSpanString(final Context c, final long timestamp) { + StringBuilder format = new StringBuilder(); + if (DateUtils.isToday(timestamp)) {} + else if (isWithin(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); + else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format.append("MMM d, "); + else format.append("MMM d, yyyy, "); + + if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); + else format.append("hh:mm a"); + + return getFormattedDateTime(timestamp, format.toString()); + } + + public static String getExtendedRelativeTimeSpanString(final Context c, final long timestamp) { + if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + return c.getString(R.string.now); + } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { + int mins = (int)TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); + return c.getResources().getQuantityString(R.plurals.n_minutes, mins, mins); + } else { + return getExtendedTimeSpanString(c, timestamp); + } + } + + public static String getRelativeDate(@NonNull Context context, + long timestamp) + { + if (isToday(timestamp)) { + return context.getString(R.string.today); + } else if (isYesterday(timestamp)) { + return context.getString(R.string.yesterday); + } else { + return getFormattedDateTime(timestamp, "EEEE, MMMM d, yyyy"); + } + } + + private static String getLocalizedPattern(String template) { + return DateFormat.getBestDateTimePattern(Util.getLocale(), template); + } + + public static String getFormatedDuration(long millis) { + return String.format("%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(millis), + TimeUnit.MILLISECONDS.toSeconds(millis-(TimeUnit.MILLISECONDS.toMinutes(millis)*60000))); + } + + public static String getFormattedCallDuration(Context c, int seconds) { + if (seconds < 60) { + return c.getResources().getQuantityString(R.plurals.n_seconds_ext, seconds, seconds); + } + + int mins = seconds / 60; + return c.getResources().getQuantityString(R.plurals.n_minutes_ext, mins, mins); + } + + public static String getFormattedTimespan(Context c, int timestamp) { + int mins = timestamp / (1000 * 60); + if (mins / 60 == 0) { + return c.getResources().getQuantityString(R.plurals.n_minutes, mins, mins); + } + int hours = mins / 60; + return c.getResources().getQuantityString(R.plurals.n_hours, hours, hours); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java b/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java new file mode 100644 index 0000000000000000000000000000000000000000..9389728ff1fc4507d93837be370020d465b153c6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Handler; + +/** + * A class that will throttle the number of runnables executed to be at most once every specified + * interval. + * + * Useful for performing actions in response to rapid user input, such as inputting text, where you + * don't necessarily want to perform an action after every input. + * + * See http://rxmarbles.com/#debounce + */ +public class Debouncer { + + private final Handler handler; + private final long threshold; + + /** + * @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every + * {@code threshold} milliseconds. + */ + public Debouncer(long threshold) { + this.handler = new Handler(); + this.threshold = threshold; + } + + public void publish(Runnable runnable) { + handler.removeCallbacksAndMessages(null); + handler.postDelayed(runnable, threshold); + } + + public void clear() { + handler.removeCallbacksAndMessages(null); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java b/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..b1aaef80bb8c8c14b7151546001bb5b0607381ab --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Bitmap; +import android.graphics.Canvas; + +import androidx.annotation.NonNull; + +public final class DrawableUtil { + + private static final int SHORTCUT_INFO_BITMAP_SIZE = ViewUtil.dpToPx(108); + private static final int SHORTCUT_INFO_WRAPPED_SIZE = ViewUtil.dpToPx(72); + private static final int SHORTCUT_INFO_PADDING = (SHORTCUT_INFO_BITMAP_SIZE - SHORTCUT_INFO_WRAPPED_SIZE) / 2; + + public static @NonNull Bitmap wrapBitmapForShortcutInfo(@NonNull Bitmap toWrap) { + Bitmap bitmap = Bitmap.createBitmap(SHORTCUT_INFO_BITMAP_SIZE, SHORTCUT_INFO_BITMAP_SIZE, Bitmap.Config.ARGB_8888); + Bitmap scaled = Bitmap.createScaledBitmap(toWrap, SHORTCUT_INFO_WRAPPED_SIZE, SHORTCUT_INFO_WRAPPED_SIZE, true); + + Canvas canvas = new Canvas(bitmap); + canvas.drawBitmap(scaled, SHORTCUT_INFO_PADDING, SHORTCUT_INFO_PADDING, null); + + return bitmap; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java b/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java new file mode 100644 index 0000000000000000000000000000000000000000..f5760e91d1a6e3d3387c9c200ac802a2d263dc94 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicNoActionBarTheme extends DynamicTheme { + protected @StyleRes int getLightThemeStyle(@NonNull String theme) { + if (theme.equals(PURPLE)) return R.style.TextSecure_PurpleNoActionBar; + if (theme.equals(GREEN)) return R.style.TextSecure_GreenNoActionBar; + if (theme.equals(BLUE)) return R.style.TextSecure_BlueNoActionBar; + if (theme.equals(RED)) return R.style.TextSecure_RedNoActionBar; + if (theme.equals(PINK)) return R.style.TextSecure_PinkNoActionBar; + if (theme.equals(GRAY)) return R.style.TextSecure_GrayNoActionBar; + return R.style.TextSecure_LightNoActionBar; + } + + protected @StyleRes int getDarkThemeStyle(@NonNull String theme) { + if (theme.equals(PURPLE)) return R.style.TextSecure_PurpleDarkNoActionBar; + if (theme.equals(GREEN)) return R.style.TextSecure_GreenDarkNoActionBar; + if (theme.equals(BLUE)) return R.style.TextSecure_BlueDarkNoActionBar; + if (theme.equals(RED)) return R.style.TextSecure_RedDarkNoActionBar; + if (theme.equals(PINK)) return R.style.TextSecure_PinkDarkNoActionBar; + if (theme.equals(GRAY)) return R.style.TextSecure_GrayDarkNoActionBar; + return R.style.TextSecure_DarkNoActionBar; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java b/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java new file mode 100644 index 0000000000000000000000000000000000000000..234631b33b3aa7455d220a209554311ef857ec2e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AppCompatDelegate; + +import org.thoughtcrime.securesms.R; + +public class DynamicTheme { + + public static final String DARK = "dark"; + public static final String LIGHT = "light"; + public static final String SYSTEM = "system"; + public static final String PURPLE = "purple"; + public static final String GREEN = "green"; + public static final String BLUE = "blue"; + public static final String RED = "red"; + public static final String PINK = "pink"; + public static final String GRAY = "gray"; + + private int currentTheme; + + public void onCreate(Activity activity) { + //boolean wasDarkTheme = isDarkTheme; + + currentTheme = getSelectedTheme(activity); + //isDarkTheme = isDarkTheme(activity); + + activity.setTheme(currentTheme); + + // In case you introduce a CachedInflater and there are problems with the dark mode, uncomment + // this line and the line in onResume(): + //if (isDarkTheme != wasDarkTheme) { + //CachedInflater.from(activity).clear(); + //} + } + + public void onResume(Activity activity) { + if (currentTheme != getSelectedTheme(activity)) { + Intent intent = activity.getIntent(); + activity.finish(); + OverridePendingTransition.invoke(activity); + activity.startActivity(intent); + OverridePendingTransition.invoke(activity); + //CachedInflater.from(activity).clear(); + } + } + + public static void setDefaultDayNightMode(@NonNull Context context) { + String theme = Prefs.getTheme(context); + + if (!theme.equals(LIGHT) && !theme.equals(DARK)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } else if (DynamicTheme.isDarkTheme(context)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } + + //CachedInflater.from(context).clear(); + } + + + private @StyleRes int getSelectedTheme(Activity activity) { + String theme = Prefs.getTheme(activity); + if (isDarkTheme(activity)) { + return getDarkThemeStyle(theme); + } else { + return getLightThemeStyle(theme); + } + } + + protected @StyleRes int getLightThemeStyle(@NonNull String theme) { + if (theme.equals(PURPLE)) return R.style.TextSecure_PurpleTheme; + if (theme.equals(GREEN)) return R.style.TextSecure_GreenTheme; + if (theme.equals(BLUE)) return R.style.TextSecure_BlueTheme; + if (theme.equals(RED)) return R.style.TextSecure_RedTheme; + if (theme.equals(PINK)) return R.style.TextSecure_PinkTheme; + if (theme.equals(GRAY)) return R.style.TextSecure_GrayTheme; + return R.style.TextSecure_LightTheme; + } + + protected @StyleRes int getDarkThemeStyle(@NonNull String theme) { + if (theme.equals(PURPLE)) return R.style.TextSecure_PurpleDarkTheme; + if (theme.equals(GREEN)) return R.style.TextSecure_GreenDarkTheme; + if (theme.equals(BLUE)) return R.style.TextSecure_BlueDarkTheme; + if (theme.equals(RED)) return R.style.TextSecure_RedDarkTheme; + if (theme.equals(PINK)) return R.style.TextSecure_PinkDarkTheme; + if (theme.equals(GRAY)) return R.style.TextSecure_GrayDarkTheme; + return R.style.TextSecure_DarkTheme; + } + + static boolean systemThemeAvailable() { + return Build.VERSION.SDK_INT >= 29; + } + + /** + * Takes the system theme into account. + */ + public static boolean isDarkTheme(@NonNull Context context) { + String theme = Prefs.getTheme(context); + + if (!theme.equals(DARK) && !theme.equals(LIGHT) && systemThemeAvailable()) { + return isSystemInDarkTheme(context); + } else { + return theme.equals(DARK); + } + } + + // return a checkmark emoji that fits to the background of the selected theme. + public static String getCheckmarkEmoji(@NonNull Context context) { + return isDarkTheme(context) ? "✅" /*blue, white or white in a box*/ : "✔️" /*blue or black*/; + } + + private static boolean isSystemInDarkTheme(@NonNull Context context) { + return (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + private static final class OverridePendingTransition { + static void invoke(Activity activity) { + activity.overridePendingTransition(0, 0); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java b/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..f3573b5dd75890178b34c3b722dfeb60cfbe5b57 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +import org.thoughtcrime.securesms.BuildConfig; + +import java.io.File; + +public class FileProviderUtil { + + private static final String AUTHORITY = BuildConfig.APPLICATION_ID+".fileprovider"; + + public static Uri getUriFor(@NonNull Context context, @NonNull File file) throws IllegalStateException, NullPointerException { + return FileProvider.getUriForFile(context, AUTHORITY, file); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java b/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..b7a55f55132a0b35efea37f1bbf4dcf2716d8b94 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util; + +import android.text.TextUtils; + +public class FileUtils { + + public static String sanitizeFilename(String name) { + if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) { + return "(invalid)"; + } + final StringBuilder res = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + final char c = name.charAt(i); + if (isValidFilenameChar(c)) { + res.append(c); + } else { + res.append('_'); + } + } + return res.toString(); + } + + private static boolean isValidFilenameChar(char c) { + if (c <= 0x1f) { + return false; + } + switch (c) { + case '"': + case '*': + case '/': + case ':': + case '<': + case '>': + case '?': + case '\\': + case '|': + case 0x7F: + return false; + default: + return true; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java b/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java new file mode 100644 index 0000000000000000000000000000000000000000..e182dc6bc2148b574289f58eef3863539bb7eec0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import java.util.concurrent.ExecutionException; + +public interface FutureTaskListener { + public void onSuccess(V result); + public void onFailure(ExecutionException exception); +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Hash.java b/src/main/java/org/thoughtcrime/securesms/util/Hash.java new file mode 100644 index 0000000000000000000000000000000000000000..f0de2c7fcd2a2aa885606c87f2467ea976845edc --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Hash.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util; + +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Hash { + + public static String sha256(String input) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(input.getBytes(Charset.forName("UTF-8"))); + byte[] digest = messageDigest.digest(); + return String.format("%064x", new BigInteger(1, digest)); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return null; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Hex.java b/src/main/java/org/thoughtcrime/securesms/util/Hex.java new file mode 100644 index 0000000000000000000000000000000000000000..e6f06fb763dad77bd98c2de718ec2e50559c2488 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Hex.java @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import java.io.IOException; + +/** + * Utility for generating hex dumps. + */ +public class Hex { + + private final static char[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + public static String toStringCondensed(byte[] bytes) { + StringBuffer buf = new StringBuffer(); + for (int i=0;i> 1]; + + // two characters form the hex value. + for (int i = 0, j = 0; j < len; i++) { + int f = Character.digit(data[j], 16) << 4; + j++; + f = f | Character.digit(data[j], 16); + j++; + out[i] = (byte) (f & 0xFF); + } + + return out; + } + + private static void appendHexChar(StringBuffer buf, int b) { + buf.append(HEX_DIGITS[(b >> 4) & 0xf]); + buf.append(HEX_DIGITS[b & 0xf]); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java b/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..70c4544ccc0ef02efdebab82a24efd86064082a5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.util; + + +import android.app.PendingIntent; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +public class IntentUtils { + + public static boolean isResolvable(@NonNull Context context, @NonNull Intent intent) { + List resolveInfoList = context.getPackageManager().queryIntentActivities(intent, 0); + return resolveInfoList != null && resolveInfoList.size() > 1; + } + + public static void showInBrowser(Context context, String url) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + try { + context.startActivity(browserIntent); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.no_browser_installed, Toast.LENGTH_LONG).show(); + } + } + + public static int FLAG_MUTABLE() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return PendingIntent.FLAG_MUTABLE; + } else { + return 0; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java b/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..c248527ad6ef7b87a7f48ac3a764d089d23db429 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.util; + +import android.util.Base64; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; + +public class JsonUtils { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); + objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); + } + + public static byte[] decodeBase64(String base64) { + if (base64 == null) { + return null; + } + return Base64.decode(base64, Base64.NO_WRAP | Base64.NO_PADDING); + } + + public static T fromJson(byte[] serialized, Class clazz) throws IOException { + return fromJson(new String(serialized), clazz); + } + + public static T fromJson(String serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static T fromJson(InputStream serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static T fromJson(Reader serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static String toJson(Object object) throws IOException { + return objectMapper.writeValueAsString(object); + } + + public static ObjectMapper getMapper() { + return objectMapper; + } + + public static String optString(JSONObject obj, String name) { + try { + return obj.optString(name); + } catch(Exception e) { + return ""; + } + } + + public static boolean optBoolean(JSONObject obj, String name) { + try { + return obj.optBoolean(name); + } catch(Exception e) { + return false; + } + } + + public static class SaneJSONObject { + + private final JSONObject delegate; + + public SaneJSONObject(JSONObject delegate) { + this.delegate = delegate; + } + + public String getString(String name) throws JSONException { + if (delegate.isNull(name)) return null; + else return delegate.getString(name); + } + + public long getLong(String name) throws JSONException { + return delegate.getLong(name); + } + + public boolean isNull(String name) { + return delegate.isNull(name); + } + + public int getInt(String name) throws JSONException { + return delegate.getInt(name); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java b/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java new file mode 100644 index 0000000000000000000000000000000000000000..b89308918d48aa8d13e2af4f58b9a187319b2a32 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.util; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class LRUCache extends LinkedHashMap { + + private final int maxSize; + + public LRUCache(int maxSize) { + this.maxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry (Map.Entry eldest) { + return size() > maxSize; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Linkifier.java b/src/main/java/org/thoughtcrime/securesms/util/Linkifier.java new file mode 100644 index 0000000000000000000000000000000000000000..88ee1a94f02c26bb063aa46f3850a30d26e5efed --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Linkifier.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.util; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.URLSpan; +import android.text.util.Linkify; + +import java.util.regex.Pattern; + +/* Utility for text linkify-ing */ +public class Linkifier { + private static final Pattern CMD_PATTERN = Pattern.compile("(?<=^|\\s)/[a-zA-Z][a-zA-Z@\\d_/.-]{0,254}"); + private static final Pattern CUSTOM_PATTERN = Pattern.compile("(?<=^|\\s)(OPENPGP4FPR|openpgp4fpr|mumble|geo|gemini):[^ \\n]+"); + private static final Pattern PROXY_PATTERN = Pattern.compile("(?<=^|\\s)(SOCKS5|socks5|ss|SS):[^ \\n]+"); + private static final Pattern PHONE_PATTERN + = Pattern.compile( // sdd = space, dot, or dash + "(?<=^|\\s|\\.|\\()" // no letter at start + + "(\\+[0-9]+[\\- \\.]*)?" // +* + + "(\\([0-9]+\\)[\\- \\.]*)?" // ()* + + "([0-9][0-9\\- \\.]{3,}[0-9])" // + (5 characters min) + + "(?=$|\\s|\\.|\\))"); // no letter at end + + private static int brokenPhoneLinkifier = -1; + + private static boolean internalPhoneLinkifierNeeded() { + if (brokenPhoneLinkifier == -1) { // unset + if(Linkify.addLinks(new SpannableString("a100b"), Linkify.PHONE_NUMBERS)) { + brokenPhoneLinkifier = 1; // true + } else { + brokenPhoneLinkifier = 0; // false + } + } + return brokenPhoneLinkifier == 1; + } + + private static void replaceURLSpan(Spannable messageBody) { + URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class); + for (URLSpan urlSpan : urlSpans) { + int start = messageBody.getSpanStart(urlSpan); + int end = messageBody.getSpanEnd(urlSpan); + // LongClickCopySpan must not be derived from URLSpan, otherwise links will be removed on the next addLinks() call + messageBody.setSpan(new LongClickCopySpan(urlSpan.getURL()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + public static Spannable linkify(Spannable messageBody) { + // linkify commands such as `/echo` - + // do this first to avoid `/xkcd_123456` to be treated partly as a phone number + Linkify.addLinks(messageBody, CMD_PATTERN, "cmd:", null, null); + replaceURLSpan(messageBody); // replace URLSpan so that it is not removed on the next addLinks() call + + Linkify.addLinks(messageBody, CUSTOM_PATTERN, null, null, null); + replaceURLSpan(messageBody); + + if (Linkify.addLinks(messageBody, PROXY_PATTERN, null, null, null)) { + replaceURLSpan(messageBody); // replace URLSpan so that it is not removed on the next addLinks() call + } + + int flags; + if (internalPhoneLinkifierNeeded()) { + if (Linkify.addLinks(messageBody, PHONE_PATTERN, "tel:", Linkify.sPhoneNumberMatchFilter, Linkify.sPhoneNumberTransformFilter)) { + replaceURLSpan(messageBody); // replace URLSpan so that it is not removed on the next addLinks() call + } + flags = Linkify.EMAIL_ADDRESSES|Linkify.WEB_URLS; + } else { + flags = Linkify.EMAIL_ADDRESSES|Linkify.WEB_URLS|Linkify.PHONE_NUMBERS; + } + + // linkyfiy urls etc., this removes all existing URLSpan + if (Linkify.addLinks(messageBody, flags)) { + replaceURLSpan(messageBody); + } + + return messageBody; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java b/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java new file mode 100644 index 0000000000000000000000000000000000000000..068772aa4dfbf71c00df3326035c9b0548a2c6db --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java @@ -0,0 +1,126 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; + +public class ListenableFutureTask extends FutureTask { + + private final List> listeners = new LinkedList<>(); + + @Nullable + private final Object identifier; + + @Nullable + private final Executor callbackExecutor; + + public ListenableFutureTask(Callable callable) { + this(callable, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier) { + this(callable, identifier, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier, @Nullable Executor callbackExecutor) { + super(callable); + this.identifier = identifier; + this.callbackExecutor = callbackExecutor; + } + + + public ListenableFutureTask(final V result) { + this(result, null); + } + + public ListenableFutureTask(final V result, @Nullable Object identifier) { + super(new Callable() { + @Override + public V call() throws Exception { + return result; + } + }); + this.identifier = identifier; + this.callbackExecutor = null; + this.run(); + } + + public synchronized void addListener(FutureTaskListener listener) { + if (this.isDone()) { + callback(listener); + } else { + this.listeners.add(listener); + } + } + + public synchronized void removeListener(FutureTaskListener listener) { + this.listeners.remove(listener); + } + + @Override + protected synchronized void done() { + callback(); + } + + private void callback() { + Runnable callbackRunnable = new Runnable() { + @Override + public void run() { + for (FutureTaskListener listener : listeners) { + callback(listener); + } + } + }; + + if (callbackExecutor == null) callbackRunnable.run(); + else callbackExecutor.execute(callbackRunnable); + } + + private void callback(FutureTaskListener listener) { + if (listener != null) { + try { + listener.onSuccess(get()); + } catch (InterruptedException e) { + throw new AssertionError(e); + } catch (ExecutionException e) { + listener.onFailure(e); + } + } + } + + @Override + public boolean equals(Object other) { + if (other != null && other instanceof ListenableFutureTask && this.identifier != null) { + return identifier.equals(other); + } else { + return super.equals(other); + } + } + + @Override + public int hashCode() { + if (identifier != null) return identifier.hashCode(); + else return super.hashCode(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java b/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java new file mode 100644 index 0000000000000000000000000000000000000000..6e898c92d2d74428a941e6aa419b1d4787c7cb18 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.ConversationActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.qr.QrCodeHandler; + +public class LongClickCopySpan extends ClickableSpan { + private static final String PREFIX_MAILTO = "mailto:"; + private static final String PREFIX_TEL = "tel:"; + private static final String PREFIX_CMD = "cmd:"; + + private boolean isHighlighted; + @ColorInt + private int highlightColor; + private final String url; + + public LongClickCopySpan(String url) { + this.url = url; + } + + private void openChat(Activity activity, DcContact contact) { + DcContext dcContext = DcHelper.getContext(activity); + int chatId = dcContext.createChatByContactId(contact.getId()); + if (chatId != 0) { + Intent intent = new Intent(activity, ConversationActivity.class); + intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); + activity.startActivity(intent); + } + } + + @Override + public void onClick(View widget) { + if (url.startsWith(PREFIX_CMD)) { + try { + String cmd = url.substring(PREFIX_CMD.length()); + ConversationActivity activity = (ConversationActivity) widget.getContext(); + activity.setDraftText(cmd + " "); + } catch (Exception e) { + e.printStackTrace(); + } + } else if (url.startsWith(PREFIX_MAILTO) && !url.contains("?")) { + try { + String addr = prepareUrl(url); + Activity activity = (Activity) widget.getContext(); + DcContext dcContext = DcHelper.getContext(activity); + + int contactId = dcContext.lookupContactIdByAddr(addr); + if (contactId == 0 && dcContext.mayBeValidAddr(addr)) { + contactId = dcContext.createContact(null, addr); + } + DcContact contact = dcContext.getContact(contactId); + if (contact.getId() != 0 && !contact.isBlocked() && dcContext.getChatIdByContactId(contact.getId()) != 0) { + openChat(activity, contact); + } else { + new AlertDialog.Builder(activity) + .setMessage(activity.getString(R.string.ask_start_chat_with, contact.getDisplayName())) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + openChat(activity, contact); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } else if (Util.isInviteURL(url)) { + QrCodeHandler qrCodeHandler = new QrCodeHandler((Activity) widget.getContext()); + qrCodeHandler.handleQrData(url); + } else { + Activity activity = (Activity) widget.getContext(); + DcContext dcContext = DcHelper.getContext(activity); + if (dcContext.checkQr(url).getState() == DcContext.DC_QR_PROXY) { + QrCodeHandler qrCodeHandler = new QrCodeHandler(activity); + qrCodeHandler.handleQrData(url); + } else { + IntentUtils.showInBrowser(widget.getContext(), url); + } + } + } + + void onLongClick(View widget) { + Context context = widget.getContext(); + + if (url.startsWith(PREFIX_CMD)) { + Util.writeTextToClipboard(context, url.substring(PREFIX_CMD.length())); + Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + } else { + String preparedUrl = prepareUrl(url); + new AlertDialog.Builder(context) + .setTitle(preparedUrl) + .setItems(new CharSequence[]{ + context.getString(R.string.menu_copy_to_clipboard) + }, + (dialogInterface, i) -> { + Util.writeTextToClipboard(context, preparedUrl); + Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + @Override + public void updateDrawState(TextPaint ds) { + super.updateDrawState(ds); + ds.bgColor = highlightColor; + ds.setUnderlineText(!isHighlighted); + } + + void setHighlighted(boolean highlighted, @ColorInt int highlightColor) { + this.isHighlighted = highlighted; + this.highlightColor = highlightColor; + } + + private String prepareUrl(String url) { + if (url.startsWith(PREFIX_MAILTO)) { + return url.substring(PREFIX_MAILTO.length()).split("\\?")[0]; + } else if (url.startsWith(PREFIX_TEL)) { + return url.substring(PREFIX_TEL.length()); + } + return url; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java b/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java new file mode 100644 index 0000000000000000000000000000000000000000..359aa5907155687fbfbef714ae1967e6ee5a519d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +public class LongClickMovementMethod extends LinkMovementMethod { + @SuppressLint("StaticFieldLeak") + private static LongClickMovementMethod sInstance; + + private final GestureDetector gestureDetector; + private View widget; + private LongClickCopySpan currentSpan; + + private LongClickMovementMethod(final Context context) { + gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public void onLongPress(MotionEvent e) { + if (currentSpan != null && widget != null) { + currentSpan.onLongClick(widget); + widget = null; + currentSpan = null; + } + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (currentSpan != null && widget != null) { + currentSpan.onClick(widget); + widget = null; + currentSpan = null; + } + return true; + } + }); + } + + @Override + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP || + action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class); + if (longClickCopySpan.length != 0) { + LongClickCopySpan aSingleSpan = longClickCopySpan[0]; + if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), + buffer.getSpanEnd(aSingleSpan)); + aSingleSpan.setHighlighted(true, + ContextCompat.getColor(widget.getContext(), R.color.touch_highlight)); + } else { + Selection.removeSelection(buffer); + aSingleSpan.setHighlighted(false, Color.TRANSPARENT); + } + + this.currentSpan = aSingleSpan; + this.widget = widget; + return gestureDetector.onTouchEvent(event); + } + } else if (action == MotionEvent.ACTION_CANCEL) { + // Remove Selections. + LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), + Selection.getSelectionEnd(buffer), LongClickCopySpan.class); + for (LongClickCopySpan aSpan : spans) { + aSpan.setHighlighted(false, Color.TRANSPARENT); + } + Selection.removeSelection(buffer); + return gestureDetector.onTouchEvent(event); + } + return super.onTouchEvent(widget, buffer, event); + } + + public static LongClickMovementMethod getInstance(Context context) { + if (sInstance == null) { + sInstance = new LongClickMovementMethod(context.getApplicationContext()); + } + return sInstance; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/MailtoUtil.java b/src/main/java/org/thoughtcrime/securesms/util/MailtoUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..c2080270bf97e7a2819182946bfeb487930b12f0 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/MailtoUtil.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.util; + +import android.net.MailTo; +import android.net.Uri; + +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +public class MailtoUtil { + private static final String MAILTO = "mailto"; + private static final String SUBJECT = "subject"; + private static final String BODY = "body"; + private static final String QUERY_SEPARATOR = "&"; + private static final String KEY_VALUE_SEPARATOR = "="; + + public static boolean isMailto(Uri uri) { + return uri != null && MAILTO.equals(uri.getScheme()); + } + + public static String[] getRecipients(Uri uri) { + String[] recipientsArray = new String[0]; + if (uri != null) { + MailTo mailto = MailTo.parse(uri.toString()); + String recipientsList = mailto.getTo(); + if(recipientsList != null && !recipientsList.trim().isEmpty()) { + recipientsArray = recipientsList.trim().split(","); + } + } + return recipientsArray; + } + + public static String getText(Uri uri) { + Map mailtoQueryMap = getMailtoQueryMap(uri); + String textToShare = mailtoQueryMap.get(SUBJECT); + String body = mailtoQueryMap.get(BODY); + if (body != null && !body.isEmpty()) { + if (textToShare != null && !textToShare.isEmpty()) { + textToShare += "\n" + body; + } else { + textToShare = body; + } + } + return textToShare != null? textToShare : ""; + } + + private static Map getMailtoQueryMap(Uri uri) { + Map mailtoQueryMap = new HashMap<>(); + String query = uri.getEncodedQuery(); + if (query != null && !query.isEmpty()) { + String[] queryArray = query.split(QUERY_SEPARATOR); + for(String queryEntry : queryArray) { + String[] queryEntryArray = queryEntry.split(KEY_VALUE_SEPARATOR); + try { + mailtoQueryMap.put(queryEntryArray[0], URLDecoder.decode(queryEntryArray[1], "UTF-8")); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + return mailtoQueryMap; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/MarkdownUtil.java b/src/main/java/org/thoughtcrime/securesms/util/MarkdownUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..ed24d6dcec13b2f370579886df9ffdc28c7c6a1a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/MarkdownUtil.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.text.Spannable; + +import androidx.annotation.NonNull; + +import org.commonmark.node.FencedCodeBlock; +import org.commonmark.parser.Parser; + +import java.util.Collections; +import java.util.HashSet; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.SoftBreakAddsNewLinePlugin; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.inlineparser.HtmlInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; + + +public class MarkdownUtil { + private static MarkdownUtil instance; + private final Markwon markwon; + + private MarkdownUtil(final Context context) { + markwon = Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder().excludeInlineProcessor(HtmlInlineProcessor.class); + }); + } + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.enabledBlockTypes(new HashSet<>(Collections.singletonList(FencedCodeBlock.class))); + } + }) + .build(); + } + + private static MarkdownUtil getInstance(Context context) { + if (instance == null) { + instance = new MarkdownUtil(context.getApplicationContext()); + } + return instance; + } + + public static Spannable toMarkdown(Context context, String text) { + return (Spannable) getInstance(context).markwon.toMarkdown(text); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..7b205b9925f60d0adc4cf716afcf7aa4d87d7eaa --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -0,0 +1,314 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.b44t.messenger.DcMsg; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.gif.GifDrawable; + +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.mms.VcardSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.ExecutionException; + +public class MediaUtil { + + private static final String TAG = MediaUtil.class.getSimpleName(); + + public static final String IMAGE_WEBP = "image/webp"; + public static final String IMAGE_JPEG = "image/jpeg"; + public static final String IMAGE_GIF = "image/gif"; + public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_UNSPECIFIED = "audio/*"; + public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String OCTET = "application/octet-stream"; + public static final String WEBXDC = "application/webxdc+zip"; + public static final String VCARD = "text/vcard"; + + + public static Slide getSlideForMsg(Context context, DcMsg dcMsg) { + Slide slide = null; + if (dcMsg.getType() == DcMsg.DC_MSG_GIF) { + slide = new GifSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_IMAGE) { + slide = new ImageSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_STICKER) { + slide = new StickerSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_VIDEO) { + slide = new VideoSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_AUDIO + || dcMsg.getType() == DcMsg.DC_MSG_VOICE) { + slide = new AudioSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_VCARD) { + slide = new VcardSlide(context, dcMsg); + } else if (dcMsg.getType() == DcMsg.DC_MSG_FILE + || dcMsg.getType() == DcMsg.DC_MSG_WEBXDC) { + slide = new DocumentSlide(context, dcMsg); + } + + return slide; + } + + public static @Nullable String getMimeType(Context context, Uri uri) { + if (uri == null) return null; + + if (PersistentBlobProvider.isAuthority(context, uri)) { + return PersistentBlobProvider.getMimeType(context, uri); + } + + String type = context.getContentResolver().getType(uri); + if (type == null) { + final String extension = getFileExtensionFromUrl(uri.toString()); + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + if (type == null) { + type = "application/octet-stream"; + } + } + return getCorrectedMimeType(type); + } + + public static @Nullable String getCorrectedMimeType(@Nullable String mimeType) { + if (mimeType == null) return null; + + switch(mimeType) { + case "image/jpg": + return MimeTypeMap.getSingleton().hasMimeType(IMAGE_JPEG) + ? IMAGE_JPEG + : mimeType; + default: + return mimeType; + } + } + + /** + * This is a version of android.webkit.MimeTypeMap.getFileExtensionFromUrl() that + * doesn't refuse to do its job when there are characters in the URL it doesn't know. + * Using MimeTypeMap.getFileExtensionFromUrl() led to bugs like this one: + * https://github.com/deltachat/deltachat-android/issues/2306 + * + * @return The url's file extension, or "" if there is none. + */ + public static String getFileExtensionFromUrl(String url) { + if (TextUtils.isEmpty(url)) return ""; + + int fragment = url.lastIndexOf('#'); + if (fragment > 0) { + url = url.substring(0, fragment); + } + + int query = url.lastIndexOf('?'); + if (query > 0) { + url = url.substring(0, query); + } + + int filenamePos = url.lastIndexOf('/'); + String filename = + 0 <= filenamePos ? url.substring(filenamePos + 1) : url; + + if (!filename.isEmpty()) { + int dotPos = filename.lastIndexOf('.'); + if (0 <= dotPos) { + return filename.substring(dotPos + 1); + } + } + + return ""; + } + + public static long getMediaSize(Context context, Uri uri) throws IOException { + InputStream in = PartAuthority.getAttachmentStream(context, uri); + if (in == null) throw new IOException("Couldn't obtain input stream."); + + long size = 0; + byte[] buffer = new byte[4096]; + int read; + + while ((read = in.read(buffer)) != -1) { + size += read; + } + in.close(); + + return size; + } + + @WorkerThread + public static Pair getDimensions(@NonNull Context context, @Nullable String contentType, @Nullable Uri uri) { + if (uri == null || !MediaUtil.isImageType(contentType)) { + return new Pair<>(0, 0); + } + + Pair dimens = null; + + if (MediaUtil.isGif(contentType)) { + try { + GifDrawable drawable = GlideApp.with(context) + .asGif() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .load(new DecryptableUri(uri)) + .submit() + .get(); + dimens = new Pair<>(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } catch (InterruptedException e) { + Log.w(TAG, "Was unable to complete work for GIF dimensions.", e); + } catch (ExecutionException e) { + Log.w(TAG, "Glide experienced an exception while trying to get GIF dimensions.", e); + } + } else { + InputStream attachmentStream = null; + try { + if (MediaUtil.isJpegType(contentType)) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getExifDimensions(attachmentStream); + attachmentStream.close(); + attachmentStream = null; + } + if (dimens == null) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getDimensions(attachmentStream); + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find file when retrieving media dimensions.", e); + } catch (IOException e) { + Log.w(TAG, "Experienced a read error when retrieving media dimensions.", e); + } catch (BitmapDecodingException e) { + Log.w(TAG, "Bitmap decoding error when retrieving dimensions.", e); + } finally { + if (attachmentStream != null) { + try { + attachmentStream.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close stream after retrieving dimensions.", e); + } + } + } + } + if (dimens == null) { + dimens = new Pair<>(0, 0); + } + Log.d(TAG, "Dimensions for [" + uri + "] are " + dimens.first + " x " + dimens.second); + return dimens; + } + + public static boolean isVideo(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/"); + } + + public static boolean isGif(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif"); + } + + public static boolean isJpegType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG); + } + + public static boolean isImageType(String contentType) { + return (null != contentType) && contentType.startsWith("image/"); + } + + public static boolean isAudioType(String contentType) { + return (null != contentType) && contentType.startsWith("audio/"); + } + + public static boolean isVideoType(String contentType) { + return (null != contentType) && contentType.startsWith("video/"); + } + + public static boolean isOctetStream(@Nullable String contentType) { + return OCTET.equals(contentType); + } + + public static boolean isImageOrVideoType(String contentType) { + return isImageType(contentType) || isVideoType(contentType); + } + + public static boolean isImageVideoOrAudioType(String contentType) { + return isImageOrVideoType(contentType) || isAudioType(contentType); + } + + public static class ThumbnailSize { + public ThumbnailSize(int width, int height) { + this.width = width; + this.height = height; + } + public int width; + public int height; + } + + public static boolean createVideoThumbnailIfNeeded(Context context, Uri dataUri, Uri thumbnailUri, ThumbnailSize retWh) { + boolean success = false; + try { + File thumbnailFile = new File(thumbnailUri.getPath()); + File dataFile = new File(dataUri.getPath()); + if (!thumbnailFile.exists() || dataFile.lastModified()>thumbnailFile.lastModified()) { + Bitmap bitmap = null; + + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(context, dataUri); + bitmap = retriever.getFrameAtTime(-1); + if (retWh!=null) { + retWh.width = bitmap.getWidth(); + retWh.height = bitmap.getHeight(); + } + retriever.release(); + + if (bitmap != null) { + FileOutputStream out = new FileOutputStream(thumbnailFile); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out); + success = true; + } + } + } + catch (Exception e) { + e.printStackTrace(); + } + return success; + } + + public static String getExtensionFromMimeType(String contentType) { + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(contentType); + if (extension != null) { + return extension; + } + + //custom handling needed for unsupported extensions on Android 4.X + switch (contentType) { + case AUDIO_AAC: + return "aac"; + case IMAGE_WEBP: + return "webp"; + case WEBXDC: + return "xdc"; + case VCARD: + return "vcf"; + } + return null; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Pair.java b/src/main/java/org/thoughtcrime/securesms/util/Pair.java new file mode 100644 index 0000000000000000000000000000000000000000..2492b1722e91d3423bc7c0603c58331700b9404b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Pair.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.thoughtcrime.securesms.util; + +public class Pair { + private final T1 v1; + private final T2 v2; + + public Pair(T1 v1, T2 v2) { + this.v1 = v1; + this.v2 = v2; + } + + public T1 first(){ + return v1; + } + + public T2 second(){ + return v2; + } + + public boolean equals(Object o) { + return o instanceof Pair && + equal(((Pair) o).first(), first()) && + equal(((Pair) o).second(), second()); + } + + public int hashCode() { + return first().hashCode() ^ second().hashCode(); + } + + private boolean equal(Object first, Object second) { + if (first == null && second == null) return true; + if (first == null || second == null) return false; + return first.equals(second); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..c1eda1232b54dae03b4a22c53c63f7e4efcf382a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Parcel; +import android.os.Parcelable; + +public class ParcelUtil { + + public static byte[] serialize(Parcelable parceable) { + Parcel parcel = Parcel.obtain(); + parceable.writeToParcel(parcel, 0); + byte[] bytes = parcel.marshall(); + parcel.recycle(); + return bytes; + } + + public static Parcel deserialize(byte[] bytes) { + Parcel parcel = Parcel.obtain(); + parcel.unmarshall(bytes, 0, bytes.length); + parcel.setDataPosition(0); + return parcel; + } + + public static T deserialize(byte[] bytes, Parcelable.Creator creator) { + Parcel parcel = deserialize(bytes); + return creator.createFromParcel(parcel); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Prefs.java b/src/main/java/org/thoughtcrime/securesms/util/Prefs.java new file mode 100644 index 0000000000000000000000000000000000000000..3729378b25a7a9c98810d5a523fac2c9a47ca1e2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Prefs.java @@ -0,0 +1,335 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentUris; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.b44t.messenger.DcAccounts; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.notifications.FcmReceiveService; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class Prefs { + + private static final String TAG = Prefs.class.getSimpleName(); + + public static final String RELIABLE_SERVICE_PREF = "pref_reliable_service"; + public static final String DISABLE_PASSPHRASE_PREF = "pref_disable_passphrase"; + public static final String THEME_PREF = "pref_theme"; + public static final String BACKGROUND_PREF = "pref_chat_background"; + + private static final String DATABASE_ENCRYPTED_SECRET = "pref_database_encrypted_secret_"; // followed by account-id + private static final String DATABASE_UNENCRYPTED_SECRET = "pref_database_unencrypted_secret_"; // followed by account-id + + public static final String RINGTONE_PREF = "pref_key_ringtone"; + private static final String VIBRATE_PREF = "pref_key_vibrate"; + private static final String CHAT_VIBRATE = "pref_chat_vibrate_"; // followed by chat-id + public static final String LED_COLOR_PREF = "pref_led_color"; + private static final String CHAT_RINGTONE = "pref_chat_ringtone_"; // followed by chat-id + public static final String SCREEN_SECURITY_PREF = "pref_screen_security"; + private static final String ENTER_SENDS_PREF = "pref_enter_sends"; + private static final String PROMPTED_DOZE_MSG_ID_PREF = "pref_prompted_doze_msg_id"; + public static final String DOZE_ASKED_DIRECTLY = "pref_doze_asked_directly"; + public static final String ASKED_FOR_NOTIFICATION_PERMISSION= "pref_asked_for_notification_permission"; + private static final String IN_THREAD_NOTIFICATION_PREF = "pref_key_inthread_notifications"; + + public static final String NOTIFICATION_PRIVACY_PREF = "pref_notification_privacy"; + public static final String NOTIFICATION_PRIORITY_PREF = "pref_notification_priority"; + + private static final String PROFILE_AVATAR_ID_PREF = "pref_profile_avatar_id"; + public static final String INCOGNITO_KEYBORAD_PREF = "pref_incognito_keyboard"; + + private static final String PREF_CONTACT_PHOTO_IDENTIFIERS = "pref_contact_photo_identifiers"; + + public static final String ALWAYS_LOAD_REMOTE_CONTENT = "pref_always_load_remote_content"; + public static final boolean ALWAYS_LOAD_REMOTE_CONTENT_DEFAULT = false; + + public static final String LAST_DEVICE_MSG_LABEL = "pref_last_device_msg_id"; + public static final String WEBXDC_STORE_URL_PREF = "pref_webxdc_store_url"; + public static final String DEFAULT_WEBXDC_STORE_URL = "https://webxdc.org/apps/"; + + public enum VibrateState { + DEFAULT(0), ENABLED(1), DISABLED(2); + private final int id; + VibrateState(int id) { this.id = id; } + public int getId() { return id; } + public static VibrateState fromId(int id) { return values()[id]; } + } + + public static void setDatabaseEncryptedSecret(@NonNull Context context, @NonNull String secret, int accountId) { + setStringPreference(context, DATABASE_ENCRYPTED_SECRET + accountId, secret); + } + + public static void setDatabaseUnencryptedSecret(@NonNull Context context, @Nullable String secret, int accountId) { + setStringPreference(context, DATABASE_UNENCRYPTED_SECRET + accountId, secret); + } + + public static @Nullable String getDatabaseUnencryptedSecret(@NonNull Context context, int accountId) { + return getStringPreference(context, DATABASE_UNENCRYPTED_SECRET + accountId, null); + } + + public static @Nullable String getDatabaseEncryptedSecret(@NonNull Context context, int accountId) { + return getStringPreference(context, DATABASE_ENCRYPTED_SECRET + accountId, null); + } + + public static boolean isIncognitoKeyboardEnabled(Context context) { + return getBooleanPreference(context, INCOGNITO_KEYBORAD_PREF, false); + } + + public static void setProfileAvatarId(Context context, int id) { + setIntegerPreference(context, PROFILE_AVATAR_ID_PREF, id); + } + + public static int getProfileAvatarId(Context context) { + return getIntegerPreference(context, PROFILE_AVATAR_ID_PREF, 0); + } + + public static int getNotificationPriority(Context context) { + return Integer.valueOf(getStringPreference(context, NOTIFICATION_PRIORITY_PREF, String.valueOf(NotificationCompat.PRIORITY_HIGH))); + } + + public static NotificationPrivacyPreference getNotificationPrivacy(Context context) { + return new NotificationPrivacyPreference(getStringPreference(context, NOTIFICATION_PRIVACY_PREF, "all")); + } + + public static boolean isInChatNotifications(Context context) { + return getBooleanPreference(context, IN_THREAD_NOTIFICATION_PREF, true); + } + + public static void setEnterSendsEnabled(Context context, boolean value) { + setBooleanPreference(context, ENTER_SENDS_PREF, value); + } + + public static boolean isEnterSendsEnabled(Context context) { + return getBooleanPreference(context, ENTER_SENDS_PREF, false); + } + + public static boolean isPasswordDisabled(Context context) { + return getBooleanPreference(context, DISABLE_PASSPHRASE_PREF, false); + } + + public static void setScreenSecurityEnabled(Context context, boolean value) { + setBooleanPreference(context, SCREEN_SECURITY_PREF, value); + } + + public static boolean isScreenSecurityEnabled(Context context) { + return getBooleanPreference(context, SCREEN_SECURITY_PREF, false); + } + + public static String getTheme(Context context) { + return getStringPreference(context, THEME_PREF, DynamicTheme.systemThemeAvailable() ? DynamicTheme.SYSTEM : DynamicTheme.LIGHT); + } + + public static String getWebxdcStoreUrl(Context context) { + return getStringPreference(context, WEBXDC_STORE_URL_PREF, DEFAULT_WEBXDC_STORE_URL); + } + + public static void setWebxdcStoreUrl(Context context, String url) { + if (url == null || url.trim().isEmpty() || DEFAULT_WEBXDC_STORE_URL.equals(url)) url = null; + setStringPreference(context, WEBXDC_STORE_URL_PREF, url); + } + + public static void setPromptedDozeMsgId(Context context, int msg_id) { + setIntegerPreference(context, PROMPTED_DOZE_MSG_ID_PREF, msg_id); + } + + public static int getPrompteDozeMsgId(Context context) { + return getIntegerPreference(context, PROMPTED_DOZE_MSG_ID_PREF, 0); + } + + public static boolean isPushEnabled(Context context) { + return BuildConfig.USE_PLAY_SERVICES; + } + + public static boolean isHardCompressionEnabled(Context context) { + return DcHelper.getContext(context).getConfigInt(DcHelper.CONFIG_MEDIA_QUALITY) == DcContext.DC_MEDIA_QUALITY_WORSE; + } + + public static boolean isLocationStreamingEnabled(Context context) { + return true; + } + + public static boolean isNewBroadcastAvailable(Context context) { + return true; + } + + + public static boolean isCallsEnabled(Context context) { + return true; + } + + // ringtone + + public static @NonNull Uri getNotificationRingtone(Context context) { + String result = getStringPreference(context, RINGTONE_PREF, Settings.System.DEFAULT_NOTIFICATION_URI.toString()); + + if (result != null && result.startsWith("file:")) { + result = Settings.System.DEFAULT_NOTIFICATION_URI.toString(); + } + + return Uri.parse(result); + } + + public static void removeNotificationRingtone(Context context) { + removePreference(context, RINGTONE_PREF); + } + + public static void setNotificationRingtone(Context context, Uri ringtone) { + setStringPreference(context, RINGTONE_PREF, ringtone.toString()); + } + + public static void setChatRingtone(Context context, int accountId, int chatId, Uri ringtone) { + final String KEY = (accountId != 0 && chatId != 0)? CHAT_RINGTONE+accountId+"."+chatId : CHAT_RINGTONE; + if(ringtone!=null) { + setStringPreference(context, KEY, ringtone.toString()); + } + else { + removePreference(context, KEY); + } + } + + public static @Nullable Uri getChatRingtone(Context context, int accountId, int chatId) { + final String KEY = (accountId != 0 && chatId != 0)? CHAT_RINGTONE+accountId+"."+chatId : CHAT_RINGTONE; + String result = getStringPreference(context, KEY, null); + return result==null? null : Uri.parse(result); + } + + public static void setReliableService(Context context, boolean value) { + setBooleanPreference(context, RELIABLE_SERVICE_PREF, value); + } + + public static boolean reliableService(Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.contains(RELIABLE_SERVICE_PREF)) { + try { + return prefs.getBoolean(RELIABLE_SERVICE_PREF, true); + } catch(Exception e) {} + } + + // if the key was unset, then calculate default value + return !isPushEnabled(context) || !DcHelper.getAccounts(context).isAllChatmail(); + } + + // vibrate + + public static boolean isNotificationVibrateEnabled(Context context) { + return getBooleanPreference(context, VIBRATE_PREF, true); + } + + public static void setChatVibrate(Context context, int accountId, int chatId, VibrateState vibrateState) { + final String KEY = (accountId != 0 && chatId != 0)? CHAT_VIBRATE+accountId+"."+chatId : CHAT_VIBRATE; + if(vibrateState!=VibrateState.DEFAULT) { + setIntegerPreference(context, KEY, vibrateState.getId()); + } + else { + removePreference(context, KEY); + } + } + + public static VibrateState getChatVibrate(Context context, int accountId, int chatId) { + final String KEY = (accountId != 0 && chatId != 0)? CHAT_VIBRATE+accountId+"."+chatId : CHAT_VIBRATE; + return VibrateState.fromId(getIntegerPreference(context, KEY, VibrateState.DEFAULT.getId())); + } + + // led + + public static String getNotificationLedColor(Context context) { + return getStringPreference(context, LED_COLOR_PREF, "blue"); + } + + // misc. + + public static String getBackgroundImagePath(Context context, int accountId) { + return getStringPreference(context, BACKGROUND_PREF+accountId, ""); + } + + public static void setBackgroundImagePath(Context context, int accountId, String path) { + setStringPreference(context, BACKGROUND_PREF+accountId, path); + } + + public static boolean getAlwaysLoadRemoteContent(Context context) { + return getBooleanPreference(context, Prefs.ALWAYS_LOAD_REMOTE_CONTENT, + Prefs.ALWAYS_LOAD_REMOTE_CONTENT_DEFAULT); + } + + // generic preference functions + + public static void setBooleanPreference(Context context, String key, boolean value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); + } + + public static boolean getBooleanPreference(Context context, String key, boolean defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, defaultValue); + } + + public static void setStringPreference(Context context, String key, String value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, value).apply(); + } + + public static String getStringPreference(Context context, String key, String defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getString(key, defaultValue); + } + + private static int getIntegerPreference(Context context, String key, int defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(key, defaultValue); + } + + private static void setIntegerPreference(Context context, String key, int value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(key, value).apply(); + } + + public static long getLongPreference(Context context, String key, long defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getLong(key, defaultValue); + } + + private static void setLongPreference(Context context, String key, long value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putLong(key, value).apply(); + } + + public static void removePreference(Context context, String key) { + PreferenceManager.getDefaultSharedPreferences(context).edit().remove(key).apply(); + } + + private static Set getStringSetPreference(Context context, String key, Set defaultValues) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.contains(key)) { + return prefs.getStringSet(key, Collections.emptySet()); + } else { + return defaultValues; + } + } + + public static void setSystemContactPhotos(Context context, Set contactPhotoIdentifiers) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putStringSet(PREF_CONTACT_PHOTO_IDENTIFIERS, contactPhotoIdentifiers).apply(); + } + + public static Uri getSystemContactPhoto(Context context, String identifier) { + List contactPhotoIdentifiers = new ArrayList<>(getStringSetPreference(context, PREF_CONTACT_PHOTO_IDENTIFIERS, new HashSet<>())); + for(String contactPhotoIdentifier : contactPhotoIdentifiers) { + if (contactPhotoIdentifier.contains(identifier)) { + String[] parts = contactPhotoIdentifier.split("\\|"); + long contactId = Long.valueOf(parts[1]); + return ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId); + } + } + return null; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..865fc3fcc5214ea291e37240a773996340d4eee7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java @@ -0,0 +1,56 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; + +import androidx.annotation.AttrRes; +import androidx.core.content.ContextCompat; + +public class ResUtil { + + public static int getColor(Context context, @AttrRes int attr) { + final TypedArray styledAttributes = context.obtainStyledAttributes(new int[]{attr}); + final int result = styledAttributes.getColor(0, -1); + styledAttributes.recycle(); + return result; + } + + public static int getDrawableRes(Context c, @AttrRes int attr) { + return getDrawableRes(c.getTheme(), attr); + } + + public static int getDrawableRes(Theme theme, @AttrRes int attr) { + final TypedValue out = new TypedValue(); + theme.resolveAttribute(attr, out, true); + return out.resourceId; + } + + public static Drawable getDrawable(Context c, @AttrRes int attr) { + try { + return ContextCompat.getDrawable(c, getDrawableRes(c, attr)); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java b/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java new file mode 100644 index 0000000000000000000000000000000000000000..42d259ae799aaebcf1b8f32efb79aa461b493883 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java @@ -0,0 +1,395 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Log; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.loader.content.CursorLoader; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.ref.WeakReference; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public class SaveAttachmentTask extends ProgressDialogAsyncTask> { + private static final String TAG = SaveAttachmentTask.class.getSimpleName(); + + static final int SUCCESS = 0; + private static final int FAILURE = 1; + private static final int WRITE_ACCESS_FAILURE = 2; + + private final WeakReference contextReference; + + public SaveAttachmentTask(Context context) { + super(context, + context.getResources().getString(R.string.one_moment), + context.getResources().getString(R.string.one_moment)); + this.contextReference = new WeakReference<>(context); + } + + @Override + protected Pair doInBackground(SaveAttachmentTask.Attachment... attachments) { + if (attachments == null || attachments.length == 0) { + throw new AssertionError("must pass in at least one attachment"); + } + + try { + Context context = contextReference.get(); + Uri uri = null; + + if (!StorageUtil.canWriteToMediaStore(context)) { + return new Pair<>(WRITE_ACCESS_FAILURE, null); + } + + if (context == null) { + return new Pair<>(FAILURE, null); + } + + for (Attachment attachment : attachments) { + if (attachment != null) { + uri = saveAttachment(context, attachment); + if (uri == null) return new Pair<>(FAILURE, null); + } + } + if (attachments.length > 1) return new Pair<>(SUCCESS, null); + else return new Pair<>(SUCCESS, uri); + } catch (IOException ioe) { + Log.w(TAG, ioe); + return new Pair<>(FAILURE, null); + } + } + + private @Nullable Uri saveAttachment(Context context, Attachment attachment) throws IOException + { + String contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType)); + String fileName = attachment.fileName; + + if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date); + fileName = sanitizeOutputFileName(fileName); + + Uri outputUri = getMediaStoreContentUriForType(contentType); + Uri mediaUri = createOutputUri(outputUri, contentType, fileName); + ContentValues updateValues = new ContentValues(); + + if (mediaUri == null) { + Log.w(TAG, "Failed to create mediaUri for " + contentType); + return null; + } + + try (InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)) { + + if (inputStream == null) { + return null; + } + + if (Util.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + try (OutputStream outputStream = new FileOutputStream(mediaUri.getPath())) { + StreamUtil.copy(inputStream, outputStream); + MediaScannerConnection.scanFile(context, new String[]{mediaUri.getPath()}, new String[]{contentType}, null); + } + } else { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(mediaUri, "w")) { + long total = StreamUtil.copy(inputStream, outputStream); + if (total > 0) { + updateValues.put(MediaStore.MediaColumns.SIZE, total); + } + } + } + } + + if (Build.VERSION.SDK_INT > 28) { + updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0); + } + + if (updateValues.size() > 0) { + getContext().getContentResolver().update(mediaUri, updateValues, null, null); + } + + return mediaUri; + } + + private @Nullable String getRealPathFromURI(Uri contentUri) { + String[] proj = {MediaStore.MediaColumns.DATA}; + CursorLoader loader = new CursorLoader(getContext(), contentUri, proj, null, null, null); + Cursor cursor = loader.loadInBackground(); + int column_index = 0; + String result = null; + if (cursor != null) { + column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + cursor.moveToFirst(); + result = cursor.getString(column_index); + cursor.close(); + } + return result; + } + + private @NonNull Uri getMediaStoreContentUriForType(@NonNull String contentType) { + if (contentType.startsWith("video/")) { + return StorageUtil.getVideoUri(); + } else if (contentType.startsWith("audio/")) { + return StorageUtil.getAudioUri(); + } else if (isMediaStoreImageType(contentType)) { + return StorageUtil.getImageUri(); + } else { + return StorageUtil.getDownloadUri(); + } + } + + /** + * Checks if the content type is a standard image format supported by Android's MediaStore. + * Non-standard image formats (like XCF, PSD, etc.) should be saved to Downloads instead. + */ + private boolean isMediaStoreImageType(@NonNull String contentType) { + if (!contentType.startsWith("image/")) { + return false; + } + return contentType.equals("image/jpeg") || + contentType.equals("image/jpg") || + contentType.equals("image/png") || + contentType.equals("image/gif") || + contentType.equals("image/webp") || + contentType.equals("image/bmp") || + contentType.equals("image/heic") || + contentType.equals("image/heif") || + contentType.equals("image/avif"); + } + + private @Nullable File ensureExternalPath(@Nullable File path) { + if (path != null && path.exists()) { + return path; + } + + if (path == null) { + File documents = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (documents.exists() || documents.mkdirs()) { + return documents; + } else { + return null; + } + } + + if (path.mkdirs()) { + return path; + } else { + return null; + } + } + + /** + * Returns a path to a shared media (or documents) directory for the type of the file. + * + * Note that this method attempts to create a directory if the path returned from + * Environment object does not exist yet. The attempt may fail in which case it attempts + * to return the default "Document" path. It finally returns null if it also fails. + * Otherwise it returns the absolute path to the directory. + * + * @param contentType a MIME type of a file + * @return an absolute path to a directory or null + */ + private @Nullable String getExternalPathForType(String contentType) { + File storage = null; + if (contentType.startsWith("video/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + } else if (contentType.startsWith("audio/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + } else if (isMediaStoreImageType(contentType)) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + } + + storage = ensureExternalPath(storage); + if (storage == null) { + return null; + } + + return storage.getAbsolutePath(); + } + + private String generateOutputFileName(@NonNull String contentType, long timestamp) { + String extension = MediaUtil.getExtensionFromMimeType(contentType); + SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss"); + String base = "deltachat-" + dateFormatter.format(timestamp); + + if (extension == null) extension = "attach"; + + return base + "." + extension; + } + + private String sanitizeOutputFileName(@NonNull String fileName) { + return new File(fileName).getName(); + } + + private @Nullable Uri createOutputUri(@NonNull Uri outputUri, @NonNull String contentType, @NonNull String fileName) + throws IOException + { + String[] fileParts = getFileNameParts(fileName); + String base = fileParts[0]; + String extension = fileParts[1]; + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + + if (MediaUtil.isOctetStream(mimeType) && MediaUtil.isImageVideoOrAudioType(contentType)) { + Log.d(TAG, "MimeTypeMap returned octet stream for media, changing to provided content type [" + contentType + "] instead."); + mimeType = contentType; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())); + contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())); + + if (Build.VERSION.SDK_INT > 28) { + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1); + } else if (Util.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + File outputDirectory = new File(outputUri.getPath()); + File outputFile = new File(outputDirectory, base + "." + extension); + + int i = 0; + while (outputFile.exists()) { + outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension); + } + + if (outputFile.isHidden()) { + throw new IOException("Specified name would not be visible"); + } + + return Uri.fromFile(outputFile); + } else { + String dir = getExternalPathForType(contentType); + if (dir == null) { + throw new IOException(String.format(Locale.ENGLISH, "Path for type: %s was not available", contentType)); + } + + String outputFileName = fileName; + String dataPath = String.format("%s/%s", dir, outputFileName); + int i = 0; + while (pathTaken(outputUri, dataPath)) { + Log.d(TAG, "The content exists. Rename and check again."); + outputFileName = base + "-" + (++i) + "." + extension; + dataPath = String.format("%s/%s", dir, outputFileName); + } + contentValues.put(MediaStore.MediaColumns.DATA, dataPath); + } + + return getContext().getContentResolver().insert(outputUri, contentValues); + } + + private boolean pathTaken(@NonNull Uri outputUri, @NonNull String dataPath) throws IOException { + try (Cursor cursor = getContext().getContentResolver().query(outputUri, + new String[] { MediaStore.MediaColumns.DATA }, + MediaStore.MediaColumns.DATA + " = ?", + new String[] { dataPath }, + null)) + { + if (cursor == null) { + throw new IOException("Something is wrong with the filename to save"); + } + return cursor.moveToFirst(); + } + } + + private String[] getFileNameParts(String fileName) { + String[] result = new String[2]; + String[] tokens = fileName.split("\\.(?=[^\\.]+$)"); + + result[0] = tokens[0]; + + if (tokens.length > 1) result[1] = tokens[1]; + else result[1] = ""; + + return result; + } + + @Override + protected void onPostExecute(final Pair result) { + super.onPostExecute(result); + final Context context = contextReference.get(); + if (context == null) return; + + switch (result.first()) { + case FAILURE: + Toast.makeText(context, + context.getResources().getString(R.string.error), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Uri uri = result.second(); + String dir; + + if (uri == null) { + dir = null; + } else { + String path = getRealPathFromURI(uri); + if (path != null) uri = Uri.parse(path); + + List segments = uri.getPathSegments(); + if (segments.size() >= 2) { + dir = segments.get(segments.size() - 2); + } else { + dir = uri.getPath(); + } + } + + Toast.makeText(context, + dir==null? context.getString(R.string.done) : context.getString(R.string.file_saved_to, dir), + Toast.LENGTH_LONG).show(); + break; + case WRITE_ACCESS_FAILURE: + Toast.makeText(context, R.string.error, + Toast.LENGTH_LONG).show(); + break; + } + } + + public static class Attachment { + public Uri uri; + public String fileName; + public String contentType; + public long date; + + public Attachment(@NonNull Uri uri, @NonNull String contentType, + long date, @Nullable String fileName) + { + if (uri == null || contentType == null || date < 0) { + throw new AssertionError("uri, content type, and date must all be specified"); + } + this.uri = uri; + this.fileName = fileName; + this.contentType = contentType; + this.date = date; + } + } + + public static void showWarningDialog(Context context, OnClickListener onAcceptListener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setCancelable(true); + builder.setMessage(R.string.ask_export_attachment); + builder.setPositiveButton(R.string.yes, onAcceptListener); + builder.setNegativeButton(R.string.no, null); + builder.show(); + } +} + diff --git a/src/main/java/org/thoughtcrime/securesms/util/ScreenLockUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ScreenLockUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d2136e1534c488b67d8e01e1ee89cbd458e387d1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ScreenLockUtil.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; + +public class ScreenLockUtil { + + public static final int REQUEST_CODE_CONFIRM_CREDENTIALS = 1001; + + public static boolean applyScreenLock(Activity activity, String title, String descr, int requestCode) { + KeyguardManager keyguardManager = (KeyguardManager) activity.getSystemService(Context.KEYGUARD_SERVICE); + Intent intent; + if (keyguardManager != null) { + intent = keyguardManager.createConfirmDeviceCredentialIntent(title, descr); + if (intent != null) { + activity.startActivityForResult(intent, requestCode); + return true; + } + } + return false; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SelectedContactsAdapter.java b/src/main/java/org/thoughtcrime/securesms/util/SelectedContactsAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..70ec1fd5f7e879829817c3e9afa3f8e8bb050273 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SelectedContactsAdapter.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.util; + +import static com.b44t.messenger.DcContact.DC_CONTACT_ID_ADD_MEMBER; +import static com.b44t.messenger.DcContact.DC_CONTACT_ID_SELF; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; + +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public class SelectedContactsAdapter extends BaseAdapter { + @NonNull private final Context context; + @Nullable private ItemClickListener itemClickListener; + @NonNull private final List contacts = new LinkedList<>(); + private final boolean isBroadcast; + private final boolean isUnencrypted; + @NonNull private final DcContext dcContext; + @NonNull private final GlideRequests glideRequests; + + public SelectedContactsAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + boolean isBroadcast, boolean isUnencrypted) + { + this.context = context; + this.glideRequests = glideRequests; + this.isBroadcast = isBroadcast; + this.isUnencrypted = isUnencrypted; + this.dcContext = DcHelper.getContext(context); + } + + public void changeData(Collection contactIds) { + contacts.clear(); + if (!isBroadcast) { + contacts.add(DC_CONTACT_ID_ADD_MEMBER); + } + if (contactIds != null) { + for (int id : contactIds) { + if (id != DC_CONTACT_ID_SELF) { + contacts.add(id); + } + } + } + if (!isBroadcast) { + contacts.add(DC_CONTACT_ID_SELF); + } + notifyDataSetChanged(); + } + + public void remove(@NonNull Integer contactId) { + if (contacts.remove(contactId)) { + notifyDataSetChanged(); + } + } + + public Set getContacts() { + final Set set = new HashSet<>(contacts.size()); + for (int i = 1; i < contacts.size(); i++) { + set.add(contacts.get(i)); + } + return set; + } + + @Override + public int getCount() { + return contacts.size(); + } + + @Override + public Object getItem(int position) { + return contacts.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(final int position, View v, final ViewGroup parent) { + if (v == null) { + v = LayoutInflater.from(context).inflate(R.layout.selected_contact_list_item, parent, false); + } + + AvatarImageView avatar = v.findViewById(R.id.contact_photo_image); + AppCompatTextView name = v.findViewById(R.id.name); + TextView phone = v.findViewById(R.id.phone); + ImageButton delete = v.findViewById(R.id.delete); + + final int contactId = (int)getItem(position); + final boolean modifiable = contactId != DC_CONTACT_ID_ADD_MEMBER && contactId != DC_CONTACT_ID_SELF; + Recipient recipient = null; + + if(contactId == DcContact.DC_CONTACT_ID_ADD_MEMBER) { + name.setText(context.getString(isBroadcast || isUnencrypted? R.string.add_recipients : R.string.group_add_members)); + name.setTypeface(null, Typeface.BOLD); + phone.setVisibility(View.GONE); + } else { + DcContact dcContact = dcContext.getContact(contactId); + recipient = new Recipient(context, dcContact); + name.setText(dcContact.getDisplayName()); + name.setTypeface(null, Typeface.NORMAL); + phone.setText(dcContact.getAddr()); + phone.setVisibility(View.VISIBLE); + } + + avatar.clear(glideRequests); + avatar.setAvatar(glideRequests, recipient, false); + delete.setVisibility(modifiable ? View.VISIBLE : View.GONE); + delete.setColorFilter(DynamicTheme.isDarkTheme(context)? Color.WHITE : Color.BLACK); + delete.setOnClickListener(view -> { + if (itemClickListener != null) { + itemClickListener.onItemDeleteClick(contacts.get(position)); + } + }); + v.setOnClickListener(view -> { + if (itemClickListener != null) { + itemClickListener.onItemClick(contacts.get(position)); + } + }); + + return v; + } + + public void setItemClickListener(@Nullable ItemClickListener listener) { + itemClickListener = listener; + } + + public interface ItemClickListener { + void onItemClick(int contactId); + void onItemDeleteClick(int contactId); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SendRelayedMessageUtil.java b/src/main/java/org/thoughtcrime/securesms/util/SendRelayedMessageUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..26f964ee3fe202cc54b6d9529b21ac700594ffa3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SendRelayedMessageUtil.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.util; + +import static org.thoughtcrime.securesms.util.ShareUtil.getForwardedMessageIDs; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedText; +import static org.thoughtcrime.securesms.util.ShareUtil.getSharedUris; +import static org.thoughtcrime.securesms.util.ShareUtil.isForwarding; +import static org.thoughtcrime.securesms.util.ShareUtil.isSharing; +import static org.thoughtcrime.securesms.util.ShareUtil.resetRelayingMessageContent; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.util.Log; +import android.provider.OpenableColumns; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + +import org.thoughtcrime.securesms.ConversationListRelayingActivity; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; + +import java.io.BufferedReader; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; + +public class SendRelayedMessageUtil { + private static final String TAG = SendRelayedMessageUtil.class.getSimpleName(); + + public static void immediatelyRelay(Activity activity, int chatId) { + immediatelyRelay(activity, new Long[]{(long) chatId}); + } + + public static void immediatelyRelay(Activity activity, final Long[] chatIds) { + ConversationListRelayingActivity.finishActivity(); + if (isForwarding(activity)) { + int[] forwardedMessageIDs = getForwardedMessageIDs(activity); + resetRelayingMessageContent(activity); + if (forwardedMessageIDs == null) return; + + Util.runOnAnyBackgroundThread(() -> { + DcContext dcContext = DcHelper.getContext(activity); + for (long longChatId : chatIds) { + int chatId = (int) longChatId; + if (dcContext.getChat(chatId).isSelfTalk()) { + for (int msgId : forwardedMessageIDs) { + DcMsg msg = dcContext.getMsg(msgId); + if (msg.canSave() && msg.getSavedMsgId() == 0 && msg.getChatId() != chatId) { + dcContext.saveMsgs(new int[]{msgId}); + } else { + handleForwarding(activity, chatId, new int[]{msgId}); + } + } + } else { + handleForwarding(activity, chatId, forwardedMessageIDs); + } + } + + }); + } else if (isSharing(activity)) { + ArrayList sharedUris = getSharedUris(activity); + String sharedText = getSharedText(activity); + String subject = ShareUtil.getSharedSubject(activity); + String sharedHtml = getHtml(activity, ShareUtil.getSharedHtml(activity)); + String msgType = ShareUtil.getSharedType(activity); + resetRelayingMessageContent(activity); + Util.runOnAnyBackgroundThread(() -> { + for (long chatId : chatIds) { + sendMultipleMsgs(activity, (int) chatId, sharedUris, msgType, sharedHtml, subject, sharedText); + } + }); + } + } + + private static void handleForwarding(Context context, int chatId, int[] forwardedMessageIDs) { + DcContext dcContext = DcHelper.getContext(context); + dcContext.forwardMsgs(forwardedMessageIDs, chatId); + } + + public static void sendMultipleMsgs(Context context, int chatId, ArrayList sharedUris, String sharedText) { + sendMultipleMsgs(context, chatId, sharedUris, null, null, null, sharedText); + } + + private static void sendMultipleMsgs(Context context, int chatId, ArrayList sharedUris, String msgType, String sharedHtml, String subject, String sharedText) { + DcContext dcContext = DcHelper.getContext(context); + ArrayList uris = sharedUris; + String text = sharedText; + + if (uris.size() == 1) { + dcContext.sendMsg(chatId, createMessage(context, uris.get(0), msgType, sharedHtml, subject, text)); + } else { + if (text != null || sharedHtml != null) { + dcContext.sendMsg(chatId, createMessage(context, null, null, sharedHtml, subject, text)); + } + for (Uri uri : uris) { + dcContext.sendMsg(chatId, createMessage(context, uri, null, null, subject, null)); + } + } + } + + public static boolean containsVideoType(Context context, ArrayList uris) { + for (final Uri uri : uris) { + final String mimeType = MediaUtil.getMimeType(context, uri); + if (MediaUtil.isVideoType(mimeType)) { + return true; + } + } + return false; + } + + public static DcMsg createMessage(Context context, Uri uri, String type, String html, String subject, String text) throws NullPointerException { + DcContext dcContext = DcHelper.getContext(context); + DcMsg message; + String mimeType = MediaUtil.getMimeType(context, uri); + if (uri == null) { + message = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT); + } else if ("sticker".equals(type)) { + message = new DcMsg(dcContext, DcMsg.DC_MSG_STICKER); + message.forceSticker(); + } else if ("image".equals(type) || MediaUtil.isImageType(mimeType)) { + message = new DcMsg(dcContext, DcMsg.DC_MSG_IMAGE); + } else if ("audio".equals(type) || MediaUtil.isAudioType(mimeType)) { + message = new DcMsg(dcContext, DcMsg.DC_MSG_AUDIO); + } else if ("video".equals(type) || MediaUtil.isVideoType(mimeType)) { + message = new DcMsg(dcContext, DcMsg.DC_MSG_VIDEO); + } else { + message = new DcMsg(dcContext, DcMsg.DC_MSG_FILE); + } + + if (uri != null) { + setFileFromUri(context, uri, message, mimeType); + } + if (html != null) { + message.setHtml(html); + } + if (subject != null) { + message.setSubject(subject); + } + if (text != null) { + message.setText(text); + } + return message; + } + + private static void setFileFromUri(Context context, Uri uri, DcMsg message, String mimeType) { + String path; + DcContext dcContext = DcHelper.getContext(context); + String filename = "cannot-resolve.jpg"; // best guess, this still leads to most images being workable if OS does weird things + try { + + if (PartAuthority.isLocalUri(uri)) { + filename = uri.getPathSegments().get(PersistentBlobProvider.FILENAME_PATH_SEGMENT); + } else if (uri.getScheme().equals("content")) { + final ContentResolver contentResolver = context.getContentResolver(); + final Cursor cursor = contentResolver.query(uri, null, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + final int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + filename = cursor.getString(nameIndex); + } + } + } finally { + cursor.close(); + } + } + + path = DcHelper.getBlobdirFile(dcContext, filename, "temp"); + + // copy content to this file + if (path != null) { + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + OutputStream outputStream = new FileOutputStream(path); + Util.copy(inputStream, outputStream); + } + } catch (Exception e) { + e.printStackTrace(); + path = null; + } + message.setFileAndDeduplicate(path, filename, mimeType); + } + + private static String getHtml(Context context, Uri uri) { + try { + InputStream in = PartAuthority.getAttachmentStream(context, uri); + BufferedReader br = new BufferedReader(new InputStreamReader(in)); + StringBuilder html = new StringBuilder(); + for (String line; (line = br.readLine()) != null; ) { + html.append(line).append('\n'); + } + if (in != null) in.close(); + br.close(); + return html.toString(); + } catch (Exception ex) { + Log.e(TAG, "failed to get HTML", ex); + return null; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..6d9356c0103889d5d116530ba0bfdb120e114fda --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.content.Context; +import android.os.Vibrator; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; + +public class ServiceUtil { + public static InputMethodManager getInputMethodManager(Context context) { + return (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public static WindowManager getWindowManager(Context context) { + return (WindowManager) context.getSystemService(Activity.WINDOW_SERVICE); + } + + public static Vibrator getVibrator(Context context) { + return (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..195793e6c87ddc76ccbbc199de7bc6337c37cc2c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java @@ -0,0 +1,230 @@ +package org.thoughtcrime.securesms.util; + +import static org.thoughtcrime.securesms.ConversationActivity.TEXT_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.MSG_SUBJECT_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.MSG_TYPE_EXTRA; +import static org.thoughtcrime.securesms.ConversationActivity.MSG_HTML_EXTRA; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; + +public class ShareUtil { + private static final String FORWARDED_MESSAGE_IDS = "forwarded_message_ids"; + private static final String SHARED_URIS = "shared_uris"; + private static final String SHARED_CONTACT_ID = "shared_contact_id"; + private static final String IS_SHARING = "is_sharing"; + private static final String IS_FROM_WEBXDC = "is_from_webxdc"; + private static final String SHARED_TITLE = "shared_title"; + private static final String DIRECT_SHARING_CHAT_ID = "direct_sharing_chat_id"; + + public static boolean isRelayingMessageContent(Activity activity) { + return isForwarding(activity) || isSharing(activity); + } + + public static boolean isForwarding(Activity activity) { + try { + return activity.getIntent().getIntArrayExtra(FORWARDED_MESSAGE_IDS) != null; + } catch (NullPointerException npe) { + return false; + } + } + + public static boolean isSharing(Activity activity) { + try { + return activity.getIntent().getBooleanExtra(IS_SHARING, false); + } catch (NullPointerException npe) { + return false; + } + } + + public static boolean isFromWebxdc(Activity activity) { + try { + return activity.getIntent().getBooleanExtra(IS_FROM_WEBXDC, false); + } catch (NullPointerException npe) { + return false; + } + } + + public static boolean isDirectSharing(Activity activity) { + try { + return activity.getIntent().getIntExtra(DIRECT_SHARING_CHAT_ID, -1) != -1; + } catch (NullPointerException npe) { + return false; + } + } + + public static int getDirectSharingChatId(Activity activity) { + try { + return activity.getIntent().getIntExtra(DIRECT_SHARING_CHAT_ID, -1); + } catch (NullPointerException npe) { + return -1; + } + } + + static int[] getForwardedMessageIDs(Activity activity) { + try { + return activity.getIntent().getIntArrayExtra(FORWARDED_MESSAGE_IDS); + } catch (NullPointerException npe) { + return null; + } + } + + public static @NonNull ArrayList getSharedUris(Activity activity) { + if (activity != null) { + Intent i = activity.getIntent(); + if (i != null) { + ArrayList uris = i.getParcelableArrayListExtra(SHARED_URIS); + if (uris != null) return uris; + } + } + return new ArrayList<>(); + } + + public static String getSharedType(Activity activity) { + try { + return activity.getIntent().getStringExtra(MSG_TYPE_EXTRA); + } catch (NullPointerException npe) { + return null; + } + } + + public static Uri getSharedHtml(Activity activity) { + try { + return activity.getIntent().getParcelableExtra(MSG_HTML_EXTRA); + } catch (NullPointerException npe) { + return null; + } + } + + public static String getSharedSubject(Activity activity) { + try { + return activity.getIntent().getStringExtra(MSG_SUBJECT_EXTRA); + } catch (NullPointerException npe) { + return null; + } + } + + public static int getSharedContactId(Activity activity) { + try { + return activity.getIntent().getIntExtra(SHARED_CONTACT_ID, 0); + } catch(Exception e) { + e.printStackTrace(); + return 0; + } + } + + public static String getSharedText(Activity activity) { + try { + return activity.getIntent().getStringExtra(TEXT_EXTRA); + } catch (NullPointerException npe) { + return null; + } + } + + public static String getSharedTitle(Activity activity) { + try { + return activity.getIntent().getStringExtra(SHARED_TITLE); + } catch (NullPointerException npe) { + return null; + } + } + + + public static void resetRelayingMessageContent(Activity activity) { + try { + activity.getIntent().removeExtra(FORWARDED_MESSAGE_IDS); + activity.getIntent().removeExtra(SHARED_URIS); + activity.getIntent().removeExtra(SHARED_CONTACT_ID); + activity.getIntent().removeExtra(IS_SHARING); + activity.getIntent().removeExtra(DIRECT_SHARING_CHAT_ID); + activity.getIntent().removeExtra(TEXT_EXTRA); + activity.getIntent().removeExtra(MSG_TYPE_EXTRA); + activity.getIntent().removeExtra(MSG_HTML_EXTRA); + activity.getIntent().removeExtra(MSG_SUBJECT_EXTRA); + } catch (NullPointerException npe) { + npe.printStackTrace(); + } + } + + public static void acquireRelayMessageContent(Activity currentActivity, @NonNull Intent newActivityIntent) { + if (isForwarding(currentActivity)) { + newActivityIntent.putExtra(FORWARDED_MESSAGE_IDS, getForwardedMessageIDs(currentActivity)); + } else if (isSharing(currentActivity)) { + newActivityIntent.putExtra(IS_SHARING, true); + if (isDirectSharing(currentActivity)) { + newActivityIntent.putExtra(DIRECT_SHARING_CHAT_ID, getDirectSharingChatId(currentActivity)); + } + if (!getSharedUris(currentActivity).isEmpty()) { + newActivityIntent.putParcelableArrayListExtra(SHARED_URIS, getSharedUris(currentActivity)); + } + if (getSharedContactId(currentActivity) != 0) { + newActivityIntent.putExtra(SHARED_CONTACT_ID, getSharedContactId(currentActivity)); + } + if (getSharedText(currentActivity) != null) { + newActivityIntent.putExtra(TEXT_EXTRA, getSharedText(currentActivity)); + } + if (getSharedSubject(currentActivity) != null) { + newActivityIntent.putExtra(MSG_SUBJECT_EXTRA, getSharedSubject(currentActivity)); + } + if (getSharedHtml(currentActivity) != null) { + newActivityIntent.putExtra(MSG_HTML_EXTRA, getSharedHtml(currentActivity)); + } + if (getSharedType(currentActivity) != null) { + newActivityIntent.putExtra(MSG_TYPE_EXTRA, getSharedType(currentActivity)); + } + } + } + + public static void setForwardingMessageIds(Intent composeIntent, int[] messageIds) { + composeIntent.putExtra(FORWARDED_MESSAGE_IDS, messageIds); + } + + public static void setIsFromWebxdc(Intent composeIntent, boolean fromWebxdc) { + composeIntent.putExtra(IS_FROM_WEBXDC, fromWebxdc); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedUris(Intent composeIntent, ArrayList uris) { + composeIntent.putParcelableArrayListExtra(SHARED_URIS, uris); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedText(Intent composeIntent, String text) { + composeIntent.putExtra(TEXT_EXTRA, text); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedSubject(Intent composeIntent, String subject) { + composeIntent.putExtra(MSG_SUBJECT_EXTRA, subject); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedHtml(Intent composeIntent, Uri html) { + composeIntent.putExtra(MSG_HTML_EXTRA, html); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedType(Intent composeIntent, String type) { + composeIntent.putExtra(MSG_TYPE_EXTRA, type); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedContactId(Intent composeIntent, int contactId) { + composeIntent.putExtra(SHARED_CONTACT_ID, contactId); + composeIntent.putExtra(IS_SHARING, true); + } + + public static void setSharedTitle(Intent composeIntent, String text) { + composeIntent.putExtra(SHARED_TITLE, text); + } + + public static void setDirectSharing(Intent composeIntent, int chatId) { + composeIntent.putExtra(DIRECT_SHARING_CHAT_ID, chatId); + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLogger.java b/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLogger.java new file mode 100644 index 0000000000000000000000000000000000000000..3b41c55fb416b3d45e751cbd9414ff4505d47084 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLogger.java @@ -0,0 +1,18 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.thoughtcrime.securesms.util; + +public interface SignalProtocolLogger { + + public static final int VERBOSE = 2; + public static final int DEBUG = 3; + public static final int INFO = 4; + public static final int WARN = 5; + public static final int ERROR = 6; + public static final int ASSERT = 7; + + public void log(int priority, String tag, String message); +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLoggerProvider.java b/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLoggerProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..b62b5f2230137d8e784f69a7fbceb34862da28f2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SignalProtocolLoggerProvider.java @@ -0,0 +1,19 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.thoughtcrime.securesms.util; + +public class SignalProtocolLoggerProvider { + + private static SignalProtocolLogger provider; + + public static SignalProtocolLogger getProvider() { + return provider; + } + + public static void setProvider(SignalProtocolLogger provider) { + SignalProtocolLoggerProvider.provider = provider; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..15a0b0c4413f555ec15454fce94a47869cc7fe7f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; + +public class SpanUtil { + + public static CharSequence italic(CharSequence sequence) { + return italic(sequence, sequence.length()); + } + + public static CharSequence italic(CharSequence sequence, int length) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence small(CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence bold(CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence color(int color, CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new ForegroundColorSpan(color), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java new file mode 100644 index 0000000000000000000000000000000000000000..a043551db60fefc244ed90c4ff28b9dcd2bd0cc4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.util; + +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import java.util.HashMap; +import java.util.Map; + +/** + * A sticky header decoration for android's RecyclerView. + * Currently only supports LinearLayoutManager in VERTICAL orientation. + */ +public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { + + private static final String TAG = StickyHeaderDecoration.class.getName(); + + private static final long NO_HEADER_ID = -1L; + + private final Map headerCache; + private final StickyHeaderAdapter adapter; + private final boolean renderInline; + private final boolean sticky; + private int screenOrientation; + + /** + * @param adapter the sticky header adapter to use + */ + public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky) { + this.adapter = adapter; + this.headerCache = new HashMap<>(); + this.renderInline = renderInline; + this.sticky = sticky; + } + + /** + * {@inheritDoc} + */ + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) + { + int position = parent.getChildAdapterPosition(view); + int headerHeight = 0; + + if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) { + View header = getHeader(parent, adapter, position).itemView; + headerHeight = getHeaderHeightForLayout(header); + } + + outRect.set(0, headerHeight, 0, 0); + } + + protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) { + boolean isReverse = isReverseLayout(parent); + int itemCount = ((RecyclerView.Adapter)adapter).getItemCount(); + + if ((isReverse && adapterPos == itemCount - 1 && adapter.getHeaderId(adapterPos) != -1) || + (!isReverse && adapterPos == 0)) + { + return true; + } + + int previous = adapterPos + (isReverse ? 1 : -1); + long headerId = adapter.getHeaderId(adapterPos); + long previousHeaderId = adapter.getHeaderId(previous); + + return headerId != NO_HEADER_ID && previousHeaderId != NO_HEADER_ID && headerId != previousHeaderId; + } + + protected ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) { + final long key = adapter.getHeaderId(position); + + if (headerCache.containsKey(key)) { + return headerCache.get(key); + } else { + final ViewHolder holder = adapter.onCreateHeaderViewHolder(parent); + final View header = holder.itemView; + + //noinspection unchecked + adapter.onBindHeaderViewHolder(holder, position); + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, + parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, + parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); + + header.measure(childWidth, childHeight); + header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); + + headerCache.put(key, holder); + + return holder; + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { + final int count = parent.getChildCount(); + + for (int layoutPos = 0; layoutPos < count; layoutPos++) { + final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos)); + + final int adapterPos = parent.getChildAdapterPosition(child); + + if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == 0 && sticky) || hasHeader(parent, adapter, adapterPos))) { + View header = getHeader(parent, adapter, adapterPos).itemView; + c.save(); + final int left = child.getLeft(); + final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); + c.translate(left, top); + header.draw(c); + c.restore(); + } + } + } + + protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, + int layoutPos) + { + int headerHeight = getHeaderHeightForLayout(header); + int top = getChildY(parent, child) - headerHeight; + if (sticky && layoutPos == 0) { + final int count = parent.getChildCount(); + final long currentId = adapter.getHeaderId(adapterPos); + // find next view with header and compute the offscreen push if needed + for (int i = 1; i < count; i++) { + int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i))); + if (adapterPosHere != RecyclerView.NO_POSITION) { + long nextId = adapter.getHeaderId(adapterPosHere); + if (nextId != currentId) { + final View next = parent.getChildAt(translatedChildPosition(parent, i)); + final int offset = getChildY(parent, next) - (headerHeight + getHeader(parent, adapter, adapterPosHere).itemView.getHeight()); + if (offset < 0) { + return offset; + } else { + break; + } + } + } + } + + if (sticky) top = Math.max(0, top); + } + + return top; + } + + private int translatedChildPosition(RecyclerView parent, int position) { + return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position; + } + + private int getChildY(RecyclerView parent, View child) { + return (int) child.getY(); + } + + protected int getHeaderHeightForLayout(View header) { + return renderInline ? 0 : header.getHeight(); + } + + private boolean isReverseLayout(final RecyclerView parent) { + return (parent.getLayoutManager() instanceof LinearLayoutManager) && + ((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout(); + } + + public void onConfigurationChanged(Configuration configuration) { + if (this.screenOrientation != configuration.orientation) { + this.screenOrientation = configuration.orientation; + invalidateLayouts(); + } + } + + public void invalidateLayouts() { + headerCache.clear(); + } + + /** + * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. + * + * @param the header view holder + */ + public interface StickyHeaderAdapter { + + /** + * Returns the header id for the item at the given position. + * + * @param position the item position + * @return the header id + */ + long getHeaderId(int position); + + /** + * Creates a new header ViewHolder. + * + * @param parent the header's view parent + * @return a view holder for the created view + */ + T onCreateHeaderViewHolder(ViewGroup parent); + + /** + * Updates the header view to reflect the header data for the given position + * @param viewHolder the header view holder + * @param position the header's item position + */ + void onBindHeaderViewHolder(T viewHolder, int position); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java new file mode 100644 index 0000000000000000000000000000000000000000..08a91a3cbd7c7e36514a91bfebf92b2c2448cbd7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.LinkedList; +import java.util.List; + +public class Stopwatch { + + private final long startTime; + private final String title; + private final List splits; + + public Stopwatch(@NonNull String title) { + this.startTime = System.currentTimeMillis(); + this.title = title; + this.splits = new LinkedList<>(); + } + + public void split(@NonNull String label) { + splits.add(new Split(System.currentTimeMillis(), label)); + } + + public void stop(@NonNull String tag) { + StringBuilder out = new StringBuilder(); + out.append("[").append(title).append("] "); + + if (splits.size() > 0) { + out.append(splits.get(0).label).append(": "); + out.append(splits.get(0).time - startTime); + out.append(" "); + } + + if (splits.size() > 1) { + for (int i = 1; i < splits.size(); i++) { + out.append(splits.get(i).label).append(": "); + out.append(splits.get(i).time - splits.get(i - 1).time); + out.append(" "); + } + + out.append("total: ").append(splits.get(splits.size() - 1).time - startTime); + } + + Log.d(tag, out.toString()); + } + + private static class Split { + final long time; + final String label; + + Split(long time, String label) { + this.time = time; + this.label = label; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java b/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..e8114e20a4b13a5489c8d1a5b7ca4c2fcd30f179 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util; + +import android.Manifest; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.permissions.Permissions; + +public class StorageUtil { + + public static boolean canWriteToMediaStore(Context context) { + return Build.VERSION.SDK_INT > 28 || + Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + public static @NonNull Uri getVideoUri() { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } + + public static @NonNull + Uri getAudioUri() { + return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + public static @NonNull Uri getImageUri() { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + + public static @NonNull Uri getDownloadUri() { + if (Build.VERSION.SDK_INT < 29) { + return getLegacyUri(Environment.DIRECTORY_DOWNLOADS); + } else { + return MediaStore.Downloads.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getLegacyUri(@NonNull String directory) { + return Uri.fromFile(Environment.getExternalStoragePublicDirectory(directory)); + } + + public static @Nullable String getCleanFileName(@Nullable String fileName) { + if (fileName == null) return null; + + fileName = fileName.replace('\u202D', '\uFFFD'); + fileName = fileName.replace('\u202E', '\uFFFD'); + + return fileName; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java b/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..884fdb62082bcf1107aecf6991617495ecfe215c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class StreamUtil { + + public static long copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + long total = 0; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + total += read; + } + + in.close(); + out.close(); + + return total; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..c72a79d52495d268084144e37ab4d66a67579af1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.util.TypedValue; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public class ThemeUtil { + + public static boolean isDarkTheme(@NonNull Context context) { + return getAttribute(context, R.attr.theme_type, "light").equals("dark"); + } + + public static int getThemedColor(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.data; + } + return Color.RED; + } + + private static String getAttribute(Context context, int attribute, String defaultValue) { + TypedValue outValue = new TypedValue(); + + if (context.getTheme().resolveAttribute(attribute, outValue, true)) { + return outValue.coerceToString().toString(); + } else { + return defaultValue; + } + } + + public static int getDummyContactColor(@NonNull Context context) { + return context.getResources().getColor(R.color.dummy_avatar_color); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ThreadUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ThreadUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..ee21bcdfc9dab29132a4c8148fe8102a8aade121 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ThreadUtil.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.util; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class ThreadUtil { + + public static ExecutorService newDynamicSingleThreadedExecutor() { + ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, + new LinkedBlockingQueue()); + executor.allowCoreThreadTimeOut(true); + + return executor; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/Util.java b/src/main/java/org/thoughtcrime/securesms/util/Util.java new file mode 100644 index 0000000000000000000000000000000000000000..144dac195d34317a0d67b4ebffe5e8b6aa8e43b6 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -0,0 +1,402 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.app.ActivityManager; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Looper; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.accessibility.AccessibilityManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.os.ConfigurationCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ComposeText; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; + +public class Util { + private static final String TAG = Util.class.getSimpleName(); + public static final String INVITE_DOMAIN = "i.delta.chat"; + + public static final Handler handler = new Handler(Looper.getMainLooper()); + + public static boolean isEmpty(ComposeText value) { + return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); + } + + public static boolean isEmpty(@Nullable CharSequence charSequence) { + return charSequence == null || charSequence.length() == 0; + } + + public static boolean isInviteURL(Uri uri) { + return INVITE_DOMAIN.equals(uri.getHost()) && uri.getEncodedFragment() != null; + } + + public static boolean isInviteURL(String url) { + try { + return isInviteURL(Uri.parse(url)); + } catch (Exception e) { + e.printStackTrace(); + } + return false; + } + + public static CharSequence getBoldedString(String value) { + SpannableString spanned = new SpannableString(value); + spanned.setSpan(new StyleSpan(Typeface.BOLD), 0, + spanned.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spanned; + } + + private static final int redDestructiveColor = 0xffff0c16; // typical "destructive red" for light/dark mode + + public static void redMenuItem(Menu menu, int id) { + MenuItem item = menu.findItem(id); + SpannableString s = new SpannableString(item.getTitle()); + s.setSpan(new ForegroundColorSpan(redDestructiveColor), 0, s.length(), 0); + item.setTitle(s); + } + + public static void redPositiveButton(AlertDialog dialog) { + redButton(dialog, AlertDialog.BUTTON_POSITIVE); + } + + public static void redButton(AlertDialog dialog, int whichButton) { + try { + dialog.getButton(whichButton).setTextColor(redDestructiveColor); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static @NonNull int[] appendInt(@Nullable int[] cur, int val) { + if (cur == null) { + return new int[] { val }; + } + final int N = cur.length; + int[] ret = new int[N + 1]; + System.arraycopy(cur, 0, ret, 0, N); + ret[N] = val; + return ret; + } + + public static boolean contains(@Nullable int[] array, int val) { + if (array == null) { + return false; + } + for (int element : array) { + if (element == val) { + return true; + } + } + return false; + } + + public static void wait(Object lock, long timeout) { + try { + lock.wait(timeout); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + + public static void close(OutputStream out) { + try { + out.close(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + public static long copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[8192]; + int read; + long total = 0; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + total += read; + } + + in.close(); + out.flush(); + out.close(); + + return total; + } + + public static boolean moveFile(String fromPath, String toPath) { + boolean success = false; + + // 1st try: a simple rename + try { + File fromFile = new File(fromPath); + File toFile = new File(toPath); + toFile.delete(); + if(fromFile.renameTo(toFile)) { + success = true; + } + } + catch (Exception e) { + e.printStackTrace(); + } + + // 2nd try: copy file + if (!success) { + try { + InputStream fromStream = new FileInputStream(fromPath); + OutputStream toStream = new FileOutputStream(toPath); + if(Util.copy(fromStream, toStream)>0) { + success = true; + } + } + catch (Exception e) { + e.printStackTrace(); + } + } + + return success; + } + + public static boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } + + public static void assertMainThread() { + if (!isMainThread()) { + throw new AssertionError("Main-thread assertion failed."); + } + } + + public static void runOnMain(final @NonNull Runnable runnable) { + if (isMainThread()) runnable.run(); + else handler.post(runnable); + } + + public static void runOnMainDelayed(final @NonNull Runnable runnable, long delayMillis) { + handler.postDelayed(runnable, delayMillis); + } + + public static void runOnMainSync(final @NonNull Runnable runnable) { + if (isMainThread()) { + runnable.run(); + } else { + final CountDownLatch sync = new CountDownLatch(1); + runOnMain(() -> { + try { + runnable.run(); + } finally { + sync.countDown(); + } + }); + try { + sync.await(); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + } + + public static void runOnBackground(final @NonNull Runnable runnable) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(runnable); + } + + public static void runOnAnyBackgroundThread(final @NonNull Runnable runnable) { + if (Util.isMainThread()) { + Util.runOnBackground(runnable); + } else { + runnable.run(); + } + } + + public static void runOnBackgroundDelayed(final @NonNull Runnable runnable, long delayMillis) { + handler.postDelayed(() -> { + AsyncTask.THREAD_POOL_EXECUTOR.execute(runnable); + }, delayMillis); + } + + public static boolean equals(@Nullable Object a, @Nullable Object b) { + return Objects.equals(a, b); + } + + public static int hashCode(@Nullable Object... objects) { + return Arrays.hashCode(objects); + } + + public static boolean isLowMemory(Context context) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + return activityManager.isLowRamDevice() || activityManager.getLargeMemoryClass() <= 64; + } + + public static int clamp(int value, int min, int max) { + return Math.min(Math.max(value, min), max); + } + + public static float clamp(float value, float min, float max) { + return Math.min(Math.max(value, min), max); + } + + public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(context.getString(R.string.app_name), text); + clipboard.setPrimaryClip(clip); + } + + public static String getTextFromClipboard(@NonNull Context context) { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard.hasPrimaryClip() && clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) { + ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0); + return item.getText().toString(); + } + return ""; + } + + public static int toIntExact(long value) { + if ((int)value != value) { + throw new ArithmeticException("integer overflow"); + } + return (int)value; + } + + public static int objectToInt(Object value) { + try { + if(value instanceof String) { + return Integer.parseInt((String)value); + } + else if (value instanceof Boolean) { + return (Boolean)value? 1 : 0; + } + else if (value instanceof Integer) { + return (Integer)value; + } + else if (value instanceof Long) { + return toIntExact((Long)value); + } + } catch (Exception e) { + } + return 0; + } + + public static String getPrettyFileSize(long sizeBytes) { + if (sizeBytes <= 0) return "0"; + + String[] units = new String[]{"B", "kB", "MB", "GB", "TB"}; + int digitGroups = (int) (Math.log10(sizeBytes) / Math.log10(1024)); + + return new DecimalFormat("#,##0.#").format(sizeBytes/Math.pow(1024, digitGroups)) + " " + units[digitGroups]; + } + + public static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + /// Converts a rgb-color as returned eg. by DcContact.getColor() + /// to argb-color as used by Android. + public static int rgbToArgbColor(int rgb) { + return Color.argb(0xFF, Color.red(rgb), Color.green(rgb), Color.blue(rgb)); + } + + private static long lastClickTime = 0; + public static boolean isClickedRecently() { + long now = System.currentTimeMillis(); + if (now - lastClickTime < 500) { + Log.i(TAG, "tap discarded"); + return true; + } + lastClickTime = now; + return false; + } + + private static AccessibilityManager accessibilityManager; + public static boolean isTouchExplorationEnabled(Context context) { + try { + if (accessibilityManager == null) { + Context applicationContext = context.getApplicationContext(); + accessibilityManager = ((AccessibilityManager) applicationContext.getSystemService(Context.ACCESSIBILITY_SERVICE)); + } + return accessibilityManager.isTouchExplorationEnabled(); + } catch (Exception e) { + return false; + } + } + + private static Locale lastLocale = null; + + public synchronized static Locale getLocale() { + if (lastLocale == null) { + try { + lastLocale = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()).get(0); + } catch (Exception e) { + e.printStackTrace(); + } + if (lastLocale == null) { + // Locale.getDefault() returns the locale the App was STARTED in, not the locale of the system. + // It just happens to be the same for the majority of use cases. + lastLocale = Locale.getDefault(); + } + } + return lastLocale; + } + + public synchronized static void localeChanged() { + lastLocale = null; + } + + public static int getLayoutDirection(Context context) { + Configuration configuration = context.getResources().getConfiguration(); + return configuration.getLayoutDirection(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..122cd38e0bc9ecb1707c1592c0f6482efb878b9f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -0,0 +1,510 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.AbsSpinner; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.views.Stub; + +import chat.delta.util.ListenableFuture; +import chat.delta.util.SettableFuture; + +public class ViewUtil { + private final static String TAG = ViewUtil.class.getSimpleName(); + + @SuppressWarnings("deprecation") + public static void setBackground(final @NonNull View v, final @Nullable Drawable drawable) { + v.setBackground(drawable); + } + + public static float getY(final @NonNull View v) { + return ViewCompat.getY(v); + } + + public static void setX(final @NonNull View v, final int x) { + ViewCompat.setX(v, x); + } + + public static float getX(final @NonNull View v) { + return ViewCompat.getX(v); + } + + public static void swapChildInPlace(ViewGroup parent, View toRemove, View toAdd, int defaultIndex) { + int childIndex = parent.indexOfChild(toRemove); + if (childIndex > -1) parent.removeView(toRemove); + parent.addView(toAdd, childIndex > -1 ? childIndex : defaultIndex); + } + + @SuppressWarnings("unchecked") + public static T inflateStub(@NonNull View parent, @IdRes int stubId) { + return (T)((ViewStub)parent.findViewById(stubId)).inflate(); + } + + @SuppressWarnings("unchecked") + public static T findById(@NonNull View parent, @IdRes int resId) { + return (T) parent.findViewById(resId); + } + + @SuppressWarnings("unchecked") + public static T findById(@NonNull Activity parent, @IdRes int resId) { + return (T) parent.findViewById(resId); + } + + public static Stub findStubById(@NonNull Activity parent, @IdRes int resId) { + return new Stub((ViewStub)parent.findViewById(resId)); + } + + private static Animation getAlphaAnimation(float from, float to, int duration) { + final Animation anim = new AlphaAnimation(from, to); + anim.setInterpolator(new FastOutSlowInInterpolator()); + anim.setDuration(duration); + return anim; + } + + public static void fadeIn(final @NonNull View view, final int duration) { + animateIn(view, getAlphaAnimation(0f, 1f, duration)); + } + + public static ListenableFuture fadeOut(final @NonNull View view, final int duration) { + return fadeOut(view, duration, View.GONE); + } + + public static ListenableFuture fadeOut(@NonNull View view, int duration, int visibility) { + return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility); + } + + public static ListenableFuture animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) { + final SettableFuture future = new SettableFuture(); + if (view.getVisibility() == visibility) { + future.set(true); + } else if (AccessibilityUtil.areAnimationsDisabled(view.getContext())) { + view.setVisibility(visibility); + future.set(true); + } else { + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationRepeat(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + view.setVisibility(visibility); + future.set(true); + } + }); + view.startAnimation(animation); + } + return future; + } + + public static void animateIn(final @NonNull View view, final @NonNull Animation animation) { + if (view.getVisibility() == View.VISIBLE) return; + + if (AccessibilityUtil.areAnimationsDisabled(view.getContext())) { + view.setVisibility(View.VISIBLE); + return; + } + + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + view.setVisibility(View.VISIBLE); + view.startAnimation(animation); + } + + @SuppressWarnings("unchecked") + public static T inflate(@NonNull LayoutInflater inflater, + @NonNull ViewGroup parent, + @LayoutRes int layoutResId) + { + return (T)(inflater.inflate(layoutResId, parent, false)); + } + + @SuppressLint("RtlHardcoded") + public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) { + if (Util.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) { + textView.setGravity(Gravity.RIGHT); + } else { + textView.setGravity(Gravity.LEFT); + } + } + + public static void mirrorIfRtl(View view, Context context) { + if (Util.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) { + view.setScaleX(-1.0f); + } + } + + public static boolean isLtr(@NonNull View view) { + return isLtr(view.getContext()); + } + + public static boolean isLtr(@NonNull Context context) { + return Util.getLayoutDirection(context) == ViewCompat.LAYOUT_DIRECTION_LTR; + } + + public static boolean isRtl(@NonNull View view) { + return isRtl(view.getContext()); + } + + public static boolean isRtl(@NonNull Context context) { + return Util.getLayoutDirection(context) == ViewCompat.LAYOUT_DIRECTION_RTL; + } + + public static int dpToPx(Context context, int dp) { + return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5); + } + + public static float pxToSp(Context context, int px) { + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + if (VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { + return TypedValue.deriveDimension(TypedValue.COMPLEX_UNIT_SP, px, metrics); + } else { + if (metrics.scaledDensity == 0) { + return 0; + } + return px / metrics.scaledDensity; + } + } + + public static void updateLayoutParams(@NonNull View view, int width, int height) { + view.getLayoutParams().width = width; + view.getLayoutParams().height = height; + view.requestLayout(); + } + + public static int getLeftMargin(@NonNull View view) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + + public static int getRightMargin(@NonNull View view) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + + public static void setLeftMargin(@NonNull View view, int margin) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin; + } else { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin; + } + view.forceLayout(); + view.requestLayout(); + } + + public static void setRightMargin(@NonNull View view, int margin) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin; + } else { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin; + } + view.forceLayout(); + view.requestLayout(); + } + + public static void setTopMargin(@NonNull View view, int margin) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin; + view.requestLayout(); + } + + public static void setPaddingTop(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom()); + } + + public static void setPaddingBottom(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding); + } + + public static int dpToPx(int dp) { + return Math.round(dp * Resources.getSystem().getDisplayMetrics().density); + } + + public static int getStatusBarHeight(@NonNull View view) { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (Build.VERSION.SDK_INT > 29 && rootWindowInsets != null) { + return rootWindowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top; + } else { + int result = 0; + int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resourceId > 0) { + result = view.getResources().getDimensionPixelSize(resourceId); + } + return result; + } + } + + // Checks if a selection is valid for a given Spinner view. + // Returns given selection if valid. + // Otherwise, to avoid ArrayIndexOutOfBoundsException, 0 is returned, assuming to refer to a good default. + public static int checkBounds(int selection, AbsSpinner view) { + if (selection < 0 || selection >= view.getCount()) { + Log.w(TAG, "index " + selection + " out of bounds of " + view.toString()); + return 0; + } + return selection; + } + + /** Return true if the system supports edge-to-edge properly */ + public static boolean isEdgeToEdgeSupported() { + return Build.VERSION.SDK_INT >= VERSION_CODES.R; + } + + /** + * Get combined insets from status bar, navigation bar and display cutout areas. + * + * @param windowInsets The window insets to extract from + * @return Combined insets using the maximum values from system bars and display cutout + */ + private static Insets getCombinedInsets(@NonNull WindowInsetsCompat windowInsets) { + Insets systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + Insets displayCutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); + return Insets.max(systemBars, displayCutout); + } + + /** + * Apply window insets to a view by adding margin to avoid drawing it behind system bars. + * Convenience method that applies insets to all sides. + * + * @param view The view to apply insets to + */ + public static void applyWindowInsetsAsMargin(@NonNull View view) { + applyWindowInsetsAsMargin(view, true, true, true, true); + } + + /** + * Apply window insets to a view by adding margin to avoid drawing it behind system bars. + * + * This method stores the original margin values in view tags to ensure that + * margin doesn't accumulate on multiple inset applications. + * + * @param view The view to apply insets to + * @param left Whether to apply left inset + * @param top Whether to apply top inset + * @param right Whether to apply right inset + * @param bottom Whether to apply bottom inset + */ + public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + // Only enable on API 30+ where WindowInsets APIs work correctly + if (!isEdgeToEdgeSupported()) return; + + // Store the original margin as a tag only if not already stored + // This prevents losing the true original margin on subsequent calls + if (view.getTag(R.id.tag_window_insets_margin_left) == null) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params instanceof ViewGroup.MarginLayoutParams) { + ViewGroup.MarginLayoutParams marginParams = (ViewGroup.MarginLayoutParams) params; + view.setTag(R.id.tag_window_insets_margin_left, marginParams.leftMargin); + view.setTag(R.id.tag_window_insets_margin_top, marginParams.topMargin); + view.setTag(R.id.tag_window_insets_margin_right, marginParams.rightMargin); + view.setTag(R.id.tag_window_insets_margin_bottom, marginParams.bottomMargin); + } + } + + ViewCompat.setOnApplyWindowInsetsListener(view, (v, windowInsets) -> { + Insets insets = getCombinedInsets(windowInsets); + + // Retrieve the original margin values from tags with null checks + Integer leftTag = (Integer) v.getTag(R.id.tag_window_insets_margin_left); + Integer topTag = (Integer) v.getTag(R.id.tag_window_insets_margin_top); + Integer rightTag = (Integer) v.getTag(R.id.tag_window_insets_margin_right); + Integer bottomTag = (Integer) v.getTag(R.id.tag_window_insets_margin_bottom); + int baseMarginLeft = leftTag != null ? leftTag : 0; + int baseMarginTop = topTag != null ? topTag : 0; + int baseMarginRight = rightTag != null ? rightTag : 0; + int baseMarginBottom = bottomTag != null ? bottomTag : 0; + + ViewGroup.LayoutParams layoutParams = v.getLayoutParams(); + if (layoutParams instanceof ViewGroup.MarginLayoutParams) { + ViewGroup.MarginLayoutParams marginParams = (ViewGroup.MarginLayoutParams) layoutParams; + marginParams.leftMargin = baseMarginLeft + insets.left; + marginParams.topMargin = baseMarginTop + insets.top; + marginParams.rightMargin = baseMarginRight + insets.right; + marginParams.bottomMargin = baseMarginBottom + insets.bottom; + v.setLayoutParams(marginParams); + } + + return windowInsets; + }); + + // Request the initial insets to be dispatched if the view is attached + if (view.isAttachedToWindow()) { + ViewCompat.requestApplyInsets(view); + } + } + + /** + * Apply window insets to a view by adding padding to avoid drawing elements behind system bars. + * Convenience method that applies insets to all sides. + * + * @param view The view to apply insets to + */ + public static void applyWindowInsets(@NonNull View view) { + applyWindowInsets(view, true, true, true, true); + } + + /** + * Apply window insets to a view by adding padding to avoid drawing elements behind system bars. + * + * This method stores the original padding values in view tags to ensure that + * padding doesn't accumulate on multiple inset applications. + * + * @param view The view to apply insets to + * @param left Whether to apply left inset + * @param top Whether to apply top inset + * @param right Whether to apply right inset + * @param bottom Whether to apply bottom inset + */ + public static void applyWindowInsets(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + // Only enable on API 30+ where WindowInsets APIs work correctly + if (!isEdgeToEdgeSupported()) return; + + // Store the original padding as a tag only if not already stored + // This prevents losing the true original padding on subsequent calls + if (view.getTag(R.id.tag_window_insets_padding_left) == null) { + view.setTag(R.id.tag_window_insets_padding_left, view.getPaddingLeft()); + view.setTag(R.id.tag_window_insets_padding_top, view.getPaddingTop()); + view.setTag(R.id.tag_window_insets_padding_right, view.getPaddingRight()); + view.setTag(R.id.tag_window_insets_padding_bottom, view.getPaddingBottom()); + } + + ViewCompat.setOnApplyWindowInsetsListener(view, (v, windowInsets) -> { + Insets insets = getCombinedInsets(windowInsets); + + // Retrieve the original padding values from tags with null checks + Integer leftTag = (Integer) v.getTag(R.id.tag_window_insets_padding_left); + Integer topTag = (Integer) v.getTag(R.id.tag_window_insets_padding_top); + Integer rightTag = (Integer) v.getTag(R.id.tag_window_insets_padding_right); + Integer bottomTag = (Integer) v.getTag(R.id.tag_window_insets_padding_bottom); + int basePaddingLeft = leftTag != null ? leftTag : 0; + int basePaddingTop = topTag != null ? topTag : 0; + int basePaddingRight = rightTag != null ? rightTag : 0; + int basePaddingBottom = bottomTag != null ? bottomTag : 0; + + v.setPadding( + left ? basePaddingLeft + insets.left : basePaddingLeft, + top ? basePaddingTop + insets.top : basePaddingTop, + right ? basePaddingRight + insets.right : basePaddingRight, + bottom ? basePaddingBottom + insets.bottom : basePaddingBottom + ); + + return windowInsets; + }); + + // Request the initial insets to be dispatched if the view is attached + if (view.isAttachedToWindow()) { + ViewCompat.requestApplyInsets(view); + } + } + + /** + * Apply the top status bar inset as the height of a view. + */ + private static void applyTopInsetAsHeight(@NonNull View view) { + ViewCompat.setOnApplyWindowInsetsListener(view, (v, windowInsets) -> { + Insets insets = getCombinedInsets(windowInsets); + + android.view.ViewGroup.LayoutParams params = v.getLayoutParams(); + if (params != null) { + params.height = insets.top; + v.setLayoutParams(params); + } + + return windowInsets; + }); + + // Request the initial insets to be dispatched if the view is attached + if (view.isAttachedToWindow()) { + ViewCompat.requestApplyInsets(view); + } + } + + /** + * Apply adjustments to the activity's custom toolbar or set height of R.id.status_bar_background for proper Edge-to-Edge display. + * + * @param activity The activity to apply the adjustments to + */ + public static void adjustToolbarForE2E(@NonNull AppCompatActivity activity) { + // Only enable on API 30+ where WindowInsets APIs work correctly + if (!isEdgeToEdgeSupported()) return; + + // The toolbar/app bar should extend behind the status bar with padding applied + View toolbar = activity.findViewById(R.id.toolbar); + if (toolbar != null) { + // Check if toolbar is inside an AppBarLayout + View parent = (View) toolbar.getParent(); + if (parent instanceof com.google.android.material.appbar.AppBarLayout) { + ViewUtil.applyWindowInsets(parent, true, true, true, false); + } else { + ViewUtil.applyWindowInsets(toolbar, true, true, true, false); + } + } + + // For activities without a custom toolbar, apply insets to status_bar_background view + View statusBarBackground = activity.findViewById(R.id.status_bar_background); + if (statusBarBackground != null) { + ViewUtil.applyTopInsetAsHeight(statusBarBackground); + ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) { + // elevation is set via status_bar_background view + // otherwise there is a drop-shadow at the top + actionBar.setElevation(0); + } + } + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java b/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java new file mode 100644 index 0000000000000000000000000000000000000000..90a4eedb44a9eb849db5d2b650dc7567acce102e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import java.util.concurrent.ExecutionException; + +import chat.delta.util.ListenableFuture; + +public abstract class AssertedSuccessListener implements ListenableFuture.Listener { + @Override + public void onFailure(ExecutionException e) { + throw new AssertionError(e); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Absent.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Absent.java new file mode 100644 index 0000000000000000000000000000000000000000..e45b1422b12af4dee42cdda659da161d12979848 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Absent.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2011 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + +import static org.thoughtcrime.securesms.util.guava.Preconditions.checkNotNull; + +import java.util.Collections; +import java.util.Set; + + +/** + * Implementation of an {@link Optional} not containing a reference. + */ + +final class Absent extends Optional { + static final Absent INSTANCE = new Absent(); + + @Override public boolean isPresent() { + return false; + } + + @Override public Object get() { + throw new IllegalStateException("value is absent"); + } + + @Override public Object or(Object defaultValue) { + return checkNotNull(defaultValue, "use orNull() instead of or(null)"); + } + + @SuppressWarnings("unchecked") // safe covariant cast + @Override public Optional or(Optional secondChoice) { + return (Optional) checkNotNull(secondChoice); + } + + @Override public Object or(Supplier supplier) { + return checkNotNull(supplier.get(), + "use orNull() instead of a Supplier that returns null"); + } + + @Override public Object orNull() { + return null; + } + + @Override public Set asSet() { + return Collections.emptySet(); + } + + @Override + public Optional transform(Function function) { + checkNotNull(function); + return Optional.absent(); + } + + @Override public boolean equals(Object object) { + return object == this; + } + + @Override public int hashCode() { + return 0x598df91c; + } + + @Override public String toString() { + return "Optional.absent()"; + } + + private Object readResolve() { + return INSTANCE; + } + + private static final long serialVersionUID = 0; +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Function.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Function.java new file mode 100644 index 0000000000000000000000000000000000000000..bd1d4c5984c1cf6ad01094b56c7b5a54ecc24887 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Function.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2007 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + + + +/** + * Determines an output value based on an input value. + * + *

See the Guava User Guide article on the use of {@code + * Function}. + * + * @author Kevin Bourrillion + * @since 2.0 (imported from Google Collections Library) + */ + +public interface Function { + /** + * Returns the result of applying this function to {@code input}. This method is generally + * expected, but not absolutely required, to have the following properties: + * + *

    + *
  • Its execution does not cause any observable side effects. + *
  • The computation is consistent with equals; that is, {@link Objects#equal + * Objects.equal}{@code (a, b)} implies that {@code Objects.equal(function.apply(a), + * function.apply(b))}. + *
+ * + * @throws NullPointerException if {@code input} is null and this function does not accept null + * arguments + */ + T apply(F input); + + /** + * Indicates whether another object is equal to this function. + * + *

Most implementations will have no reason to override the behavior of {@link Object#equals}. + * However, an implementation may also choose to return {@code true} whenever {@code object} is a + * {@link Function} that it considers interchangeable with this one. "Interchangeable" + * typically means that {@code Objects.equal(this.apply(f), that.apply(f))} is true for all + * {@code f} of type {@code F}. Note that a {@code false} result from this method does not imply + * that the functions are known not to be interchangeable. + */ + @Override + boolean equals(Object object); +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Optional.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Optional.java new file mode 100644 index 0000000000000000000000000000000000000000..185e1f07e997c9f0668f11f1b361da5c67b0f388 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Optional.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2011 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + +import static org.thoughtcrime.securesms.util.guava.Preconditions.checkNotNull; + +import java.io.Serializable; +import java.util.Set; + + +/** + * An immutable object that may contain a non-null reference to another object. Each + * instance of this type either contains a non-null reference, or contains nothing (in + * which case we say that the reference is "absent"); it is never said to "contain {@code + * null}". + * + *

A non-null {@code Optional} reference can be used as a replacement for a nullable + * {@code T} reference. It allows you to represent "a {@code T} that must be present" and + * a "a {@code T} that might be absent" as two distinct types in your program, which can + * aid clarity. + * + *

Some uses of this class include + * + *

    + *
  • As a method return type, as an alternative to returning {@code null} to indicate + * that no value was available + *
  • To distinguish between "unknown" (for example, not present in a map) and "known to + * have no value" (present in the map, with value {@code Optional.absent()}) + *
  • To wrap nullable references for storage in a collection that does not support + * {@code null} (though there are + * + * several other approaches to this that should be considered first) + *
+ * + *

A common alternative to using this class is to find or create a suitable + * null object for the + * type in question. + * + *

This class is not intended as a direct analogue of any existing "option" or "maybe" + * construct from other programming environments, though it may bear some similarities. + * + *

See the Guava User Guide article on + * using {@code Optional}. + * + * @param the type of instance that can be contained. {@code Optional} is naturally + * covariant on this type, so it is safe to cast an {@code Optional} to {@code + * Optional} for any supertype {@code S} of {@code T}. + * @author Kurt Alfred Kluever + * @author Kevin Bourrillion + * @since 10.0 + */ +public abstract class Optional implements Serializable { + /** + * Returns an {@code Optional} instance with no contained reference. + */ + @SuppressWarnings("unchecked") + public static Optional absent() { + return (Optional) Absent.INSTANCE; + } + + /** + * Returns an {@code Optional} instance containing the given non-null reference. + */ + public static Optional of(T reference) { + return new Present(checkNotNull(reference)); + } + + /** + * If {@code nullableReference} is non-null, returns an {@code Optional} instance containing that + * reference; otherwise returns {@link Optional#absent}. + */ + public static Optional fromNullable(T nullableReference) { + return (nullableReference == null) + ? Optional.absent() + : new Present(nullableReference); + } + + Optional() {} + + /** + * Returns {@code true} if this holder contains a (non-null) instance. + */ + public abstract boolean isPresent(); + + /** + * Returns the contained instance, which must be present. If the instance might be + * absent, use {@link #or(Object)} or {@link #orNull} instead. + * + * @throws IllegalStateException if the instance is absent ({@link #isPresent} returns + * {@code false}) + */ + public abstract T get(); + + /** + * Returns the contained instance if it is present; {@code defaultValue} otherwise. If + * no default value should be required because the instance is known to be present, use + * {@link #get()} instead. For a default value of {@code null}, use {@link #orNull}. + * + *

Note about generics: The signature {@code public T or(T defaultValue)} is overly + * restrictive. However, the ideal signature, {@code public S or(S)}, is not legal + * Java. As a result, some sensible operations involving subtypes are compile errors: + *

   {@code
+   *
+   *   Optional optionalInt = getSomeOptionalInt();
+   *   Number value = optionalInt.or(0.5); // error
+   *
+   *   FluentIterable numbers = getSomeNumbers();
+   *   Optional first = numbers.first();
+   *   Number value = first.or(0.5); // error}
+ * + * As a workaround, it is always safe to cast an {@code Optional} to {@code + * Optional}. Casting either of the above example {@code Optional} instances to {@code + * Optional} (where {@code Number} is the desired output type) solves the problem: + *
   {@code
+   *
+   *   Optional optionalInt = (Optional) getSomeOptionalInt();
+   *   Number value = optionalInt.or(0.5); // fine
+   *
+   *   FluentIterable numbers = getSomeNumbers();
+   *   Optional first = (Optional) numbers.first();
+   *   Number value = first.or(0.5); // fine}
+ */ + public abstract T or(T defaultValue); + + /** + * Returns this {@code Optional} if it has a value present; {@code secondChoice} + * otherwise. + */ + public abstract Optional or(Optional secondChoice); + + /** + * Returns the contained instance if it is present; {@code supplier.get()} otherwise. If the + * supplier returns {@code null}, a {@link NullPointerException} is thrown. + * + * @throws NullPointerException if the supplier returns {@code null} + */ + public abstract T or(Supplier supplier); + + /** + * Returns the contained instance if it is present; {@code null} otherwise. If the + * instance is known to be present, use {@link #get()} instead. + */ + public abstract T orNull(); + + /** + * Returns an immutable singleton {@link Set} whose only element is the contained instance + * if it is present; an empty immutable {@link Set} otherwise. + * + * @since 11.0 + */ + public abstract Set asSet(); + + /** + * If the instance is present, it is transformed with the given {@link Function}; otherwise, + * {@link Optional#absent} is returned. If the function returns {@code null}, a + * {@link NullPointerException} is thrown. + * + * @throws NullPointerException if the function returns {@code null} + * + * @since 12.0 + */ + + public abstract Optional transform(Function function); + + /** + * Returns {@code true} if {@code object} is an {@code Optional} instance, and either + * the contained references are {@linkplain Object#equals equal} to each other or both + * are absent. Note that {@code Optional} instances of differing parameterized types can + * be equal. + */ + @Override public abstract boolean equals(Object object); + + /** + * Returns a hash code for this instance. + */ + @Override public abstract int hashCode(); + + /** + * Returns a string representation for this instance. The form of this string + * representation is unspecified. + */ + @Override public abstract String toString(); + + /** + * Returns the value of each present instance from the supplied {@code optionals}, in order, + * skipping over occurrences of {@link Optional#absent}. Iterators are unmodifiable and are + * evaluated lazily. + * + * @since 11.0 (generics widened in 13.0) + */ + +// public static Iterable presentInstances( +// final Iterable> optionals) { +// checkNotNull(optionals); +// return new Iterable() { +// @Override public Iterator iterator() { +// return new AbstractIterator() { +// private final Iterator> iterator = +// checkNotNull(optionals.iterator()); +// +// @Override protected T computeNext() { +// while (iterator.hasNext()) { +// Optional optional = iterator.next(); +// if (optional.isPresent()) { +// return optional.get(); +// } +// } +// return endOfData(); +// } +// }; +// }; +// }; +// } + + private static final long serialVersionUID = 0; +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Preconditions.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Preconditions.java new file mode 100644 index 0000000000000000000000000000000000000000..1c32bb54c36429aed07f56b95fbc7223a4173a0d --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Preconditions.java @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2007 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + + +import java.util.NoSuchElementException; + + + +/** + * Simple static methods to be called at the start of your own methods to verify + * correct arguments and state. This allows constructs such as + *
+ *     if (count <= 0) {
+ *       throw new IllegalArgumentException("must be positive: " + count);
+ *     }
+ * + * to be replaced with the more compact + *
+ *     checkArgument(count > 0, "must be positive: %s", count);
+ * + * Note that the sense of the expression is inverted; with {@code Preconditions} + * you declare what you expect to be true, just as you do with an + * + * {@code assert} or a JUnit {@code assertTrue} call. + * + *

Warning: only the {@code "%s"} specifier is recognized as a + * placeholder in these messages, not the full range of {@link + * String#format(String, Object[])} specifiers. + * + *

Take care not to confuse precondition checking with other similar types + * of checks! Precondition exceptions -- including those provided here, but also + * {@link IndexOutOfBoundsException}, {@link NoSuchElementException}, {@link + * UnsupportedOperationException} and others -- are used to signal that the + * calling method has made an error. This tells the caller that it should + * not have invoked the method when it did, with the arguments it did, or + * perhaps ever. Postcondition or other invariant failures should not throw + * these types of exceptions. + * + *

See the Guava User Guide on + * using {@code Preconditions}. + * + * @author Kevin Bourrillion + * @since 2.0 (imported from Google Collections Library) + */ + +public final class Preconditions { + private Preconditions() {} + + /** + * Ensures the truth of an expression involving one or more parameters to the + * calling method. + * + * @param expression a boolean expression + * @throws IllegalArgumentException if {@code expression} is false + */ + public static void checkArgument(boolean expression) { + if (!expression) { + throw new IllegalArgumentException(); + } + } + + /** + * Ensures the truth of an expression involving one or more parameters to the + * calling method. + * + * @param expression a boolean expression + * @param errorMessage the exception message to use if the check fails; will + * be converted to a string using {@link String#valueOf(Object)} + * @throws IllegalArgumentException if {@code expression} is false + */ + public static void checkArgument( + boolean expression, Object errorMessage) { + if (!expression) { + throw new IllegalArgumentException(String.valueOf(errorMessage)); + } + } + + /** + * Ensures the truth of an expression involving one or more parameters to the + * calling method. + * + * @param expression a boolean expression + * @param errorMessageTemplate a template for the exception message should the + * check fail. The message is formed by replacing each {@code %s} + * placeholder in the template with an argument. These are matched by + * position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc. + * Unmatched arguments will be appended to the formatted message in square + * braces. Unmatched placeholders will be left as-is. + * @param errorMessageArgs the arguments to be substituted into the message + * template. Arguments are converted to strings using + * {@link String#valueOf(Object)}. + * @throws IllegalArgumentException if {@code expression} is false + * @throws NullPointerException if the check fails and either {@code + * errorMessageTemplate} or {@code errorMessageArgs} is null (don't let + * this happen) + */ + public static void checkArgument(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalArgumentException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Ensures the truth of an expression involving the state of the calling + * instance, but not involving any parameters to the calling method. + * + * @param expression a boolean expression + * @throws IllegalStateException if {@code expression} is false + */ + public static void checkState(boolean expression) { + if (!expression) { + throw new IllegalStateException(); + } + } + + /** + * Ensures the truth of an expression involving the state of the calling + * instance, but not involving any parameters to the calling method. + * + * @param expression a boolean expression + * @param errorMessage the exception message to use if the check fails; will + * be converted to a string using {@link String#valueOf(Object)} + * @throws IllegalStateException if {@code expression} is false + */ + public static void checkState( + boolean expression, Object errorMessage) { + if (!expression) { + throw new IllegalStateException(String.valueOf(errorMessage)); + } + } + + /** + * Ensures the truth of an expression involving the state of the calling + * instance, but not involving any parameters to the calling method. + * + * @param expression a boolean expression + * @param errorMessageTemplate a template for the exception message should the + * check fail. The message is formed by replacing each {@code %s} + * placeholder in the template with an argument. These are matched by + * position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc. + * Unmatched arguments will be appended to the formatted message in square + * braces. Unmatched placeholders will be left as-is. + * @param errorMessageArgs the arguments to be substituted into the message + * template. Arguments are converted to strings using + * {@link String#valueOf(Object)}. + * @throws IllegalStateException if {@code expression} is false + * @throws NullPointerException if the check fails and either {@code + * errorMessageTemplate} or {@code errorMessageArgs} is null (don't let + * this happen) + */ + public static void checkState(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalStateException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Ensures that an object reference passed as a parameter to the calling + * method is not null. + * + * @param reference an object reference + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static T checkNotNull(T reference) { + if (reference == null) { + throw new NullPointerException(); + } + return reference; + } + + /** + * Ensures that an object reference passed as a parameter to the calling + * method is not null. + * + * @param reference an object reference + * @param errorMessage the exception message to use if the check fails; will + * be converted to a string using {@link String#valueOf(Object)} + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static T checkNotNull(T reference, Object errorMessage) { + if (reference == null) { + throw new NullPointerException(String.valueOf(errorMessage)); + } + return reference; + } + + /** + * Ensures that an object reference passed as a parameter to the calling + * method is not null. + * + * @param reference an object reference + * @param errorMessageTemplate a template for the exception message should the + * check fail. The message is formed by replacing each {@code %s} + * placeholder in the template with an argument. These are matched by + * position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc. + * Unmatched arguments will be appended to the formatted message in square + * braces. Unmatched placeholders will be left as-is. + * @param errorMessageArgs the arguments to be substituted into the message + * template. Arguments are converted to strings using + * {@link String#valueOf(Object)}. + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static T checkNotNull(T reference, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (reference == null) { + // If either of these parameters is null, the right thing happens anyway + throw new NullPointerException( + format(errorMessageTemplate, errorMessageArgs)); + } + return reference; + } + + /* + * All recent hotspots (as of 2009) *really* like to have the natural code + * + * if (guardExpression) { + * throw new BadException(messageExpression); + * } + * + * refactored so that messageExpression is moved to a separate + * String-returning method. + * + * if (guardExpression) { + * throw new BadException(badMsg(...)); + * } + * + * The alternative natural refactorings into void or Exception-returning + * methods are much slower. This is a big deal - we're talking factors of + * 2-8 in microbenchmarks, not just 10-20%. (This is a hotspot optimizer + * bug, which should be fixed, but that's a separate, big project). + * + * The coding pattern above is heavily used in java.util, e.g. in ArrayList. + * There is a RangeCheckMicroBenchmark in the JDK that was used to test this. + * + * But the methods in this class want to throw different exceptions, + * depending on the args, so it appears that this pattern is not directly + * applicable. But we can use the ridiculous, devious trick of throwing an + * exception in the middle of the construction of another exception. + * Hotspot is fine with that. + */ + + /** + * Ensures that {@code index} specifies a valid element in an array, + * list or string of size {@code size}. An element index may range from zero, + * inclusive, to {@code size}, exclusive. + * + * @param index a user-supplied index identifying an element of an array, list + * or string + * @param size the size of that array, list or string + * @return the value of {@code index} + * @throws IndexOutOfBoundsException if {@code index} is negative or is not + * less than {@code size} + * @throws IllegalArgumentException if {@code size} is negative + */ + public static int checkElementIndex(int index, int size) { + return checkElementIndex(index, size, "index"); + } + + /** + * Ensures that {@code index} specifies a valid element in an array, + * list or string of size {@code size}. An element index may range from zero, + * inclusive, to {@code size}, exclusive. + * + * @param index a user-supplied index identifying an element of an array, list + * or string + * @param size the size of that array, list or string + * @param desc the text to use to describe this index in an error message + * @return the value of {@code index} + * @throws IndexOutOfBoundsException if {@code index} is negative or is not + * less than {@code size} + * @throws IllegalArgumentException if {@code size} is negative + */ + public static int checkElementIndex( + int index, int size, String desc) { + // Carefully optimized for execution by hotspot (explanatory comment above) + if (index < 0 || index >= size) { + throw new IndexOutOfBoundsException(badElementIndex(index, size, desc)); + } + return index; + } + + private static String badElementIndex(int index, int size, String desc) { + if (index < 0) { + return format("%s (%s) must not be negative", desc, index); + } else if (size < 0) { + throw new IllegalArgumentException("negative size: " + size); + } else { // index >= size + return format("%s (%s) must be less than size (%s)", desc, index, size); + } + } + + /** + * Ensures that {@code index} specifies a valid position in an array, + * list or string of size {@code size}. A position index may range from zero + * to {@code size}, inclusive. + * + * @param index a user-supplied index identifying a position in an array, list + * or string + * @param size the size of that array, list or string + * @return the value of {@code index} + * @throws IndexOutOfBoundsException if {@code index} is negative or is + * greater than {@code size} + * @throws IllegalArgumentException if {@code size} is negative + */ + public static int checkPositionIndex(int index, int size) { + return checkPositionIndex(index, size, "index"); + } + + /** + * Ensures that {@code index} specifies a valid position in an array, + * list or string of size {@code size}. A position index may range from zero + * to {@code size}, inclusive. + * + * @param index a user-supplied index identifying a position in an array, list + * or string + * @param size the size of that array, list or string + * @param desc the text to use to describe this index in an error message + * @return the value of {@code index} + * @throws IndexOutOfBoundsException if {@code index} is negative or is + * greater than {@code size} + * @throws IllegalArgumentException if {@code size} is negative + */ + public static int checkPositionIndex( + int index, int size, String desc) { + // Carefully optimized for execution by hotspot (explanatory comment above) + if (index < 0 || index > size) { + throw new IndexOutOfBoundsException(badPositionIndex(index, size, desc)); + } + return index; + } + + private static String badPositionIndex(int index, int size, String desc) { + if (index < 0) { + return format("%s (%s) must not be negative", desc, index); + } else if (size < 0) { + throw new IllegalArgumentException("negative size: " + size); + } else { // index > size + return format("%s (%s) must not be greater than size (%s)", + desc, index, size); + } + } + + /** + * Ensures that {@code start} and {@code end} specify a valid positions + * in an array, list or string of size {@code size}, and are in order. A + * position index may range from zero to {@code size}, inclusive. + * + * @param start a user-supplied index identifying a starting position in an + * array, list or string + * @param end a user-supplied index identifying a ending position in an array, + * list or string + * @param size the size of that array, list or string + * @throws IndexOutOfBoundsException if either index is negative or is + * greater than {@code size}, or if {@code end} is less than {@code start} + * @throws IllegalArgumentException if {@code size} is negative + */ + public static void checkPositionIndexes(int start, int end, int size) { + // Carefully optimized for execution by hotspot (explanatory comment above) + if (start < 0 || end < start || end > size) { + throw new IndexOutOfBoundsException(badPositionIndexes(start, end, size)); + } + } + + private static String badPositionIndexes(int start, int end, int size) { + if (start < 0 || start > size) { + return badPositionIndex(start, size, "start index"); + } + if (end < 0 || end > size) { + return badPositionIndex(end, size, "end index"); + } + // end < start + return format("end index (%s) must not be less than start index (%s)", + end, start); + } + + /** + * Substitutes each {@code %s} in {@code template} with an argument. These + * are matched by position - the first {@code %s} gets {@code args[0]}, etc. + * If there are more arguments than placeholders, the unmatched arguments will + * be appended to the end of the formatted message in square braces. + * + * @param template a non-null string containing 0 or more {@code %s} + * placeholders. + * @param args the arguments to be substituted into the message + * template. Arguments are converted to strings using + * {@link String#valueOf(Object)}. Arguments can be null. + */ + static String format(String template, + Object... args) { + template = String.valueOf(template); // null -> "null" + + // start substituting the arguments into the '%s' placeholders + StringBuilder builder = new StringBuilder( + template.length() + 16 * args.length); + int templateStart = 0; + int i = 0; + while (i < args.length) { + int placeholderStart = template.indexOf("%s", templateStart); + if (placeholderStart == -1) { + break; + } + builder.append(template.substring(templateStart, placeholderStart)); + builder.append(args[i++]); + templateStart = placeholderStart + 2; + } + builder.append(template.substring(templateStart)); + + // if we run out of placeholders, append the extra args in square braces + if (i < args.length) { + builder.append(" ["); + builder.append(args[i++]); + while (i < args.length) { + builder.append(", "); + builder.append(args[i++]); + } + builder.append(']'); + } + + return builder.toString(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Present.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Present.java new file mode 100644 index 0000000000000000000000000000000000000000..4bf3d15487c1c3637d9ed5cb92fdb24300d6b999 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Present.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2011 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + +import static org.thoughtcrime.securesms.util.guava.Preconditions.checkNotNull; + +import java.util.Collections; +import java.util.Set; + +/** + * Implementation of an {@link Optional} containing a reference. + */ + +final class Present extends Optional { + private final T reference; + + Present(T reference) { + this.reference = reference; + } + + @Override public boolean isPresent() { + return true; + } + + @Override public T get() { + return reference; + } + + @Override public T or(T defaultValue) { + checkNotNull(defaultValue, "use orNull() instead of or(null)"); + return reference; + } + + @Override public Optional or(Optional secondChoice) { + checkNotNull(secondChoice); + return this; + } + + @Override public T or(Supplier supplier) { + checkNotNull(supplier); + return reference; + } + + @Override public T orNull() { + return reference; + } + + @Override public Set asSet() { + return Collections.singleton(reference); + } + + @Override public Optional transform(Function function) { + return new Present(checkNotNull(function.apply(reference), + "Transformation function cannot return null.")); + } + + @Override public boolean equals(Object object) { + if (object instanceof Present) { + Present other = (Present) object; + return reference.equals(other.reference); + } + return false; + } + + @Override public int hashCode() { + return 0x598df91c + reference.hashCode(); + } + + @Override public String toString() { + return "Optional.of(" + reference + ")"; + } + + private static final long serialVersionUID = 0; +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/guava/Supplier.java b/src/main/java/org/thoughtcrime/securesms/util/guava/Supplier.java new file mode 100644 index 0000000000000000000000000000000000000000..8a3683c425e6bf32912eb0b68f5e18ed095fb72c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/guava/Supplier.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2007 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util.guava; + + +/** + * A class that can supply objects of a single type. Semantically, this could + * be a factory, generator, builder, closure, or something else entirely. No + * guarantees are implied by this interface. + * + * @author Harry Heymann + * @since 2.0 (imported from Google Collections Library) + */ +public interface Supplier { + /** + * Retrieves an instance of the appropriate type. The returned object may or + * may not be a new instance, depending on the implementation. + * + * @return an instance of the appropriate type + */ + T get(); +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java b/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java new file mode 100644 index 0000000000000000000000000000000000000000..b15c96137d1a0487915f46879554fecbe730c709 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.util.spans; + + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +public class CenterAlignedRelativeSizeSpan extends MetricAffectingSpan { + + private final float relativeSize; + + public CenterAlignedRelativeSizeSpan(float relativeSize) { + this.relativeSize = relativeSize; + } + + @Override + public void updateMeasureState(TextPaint p) { + updateDrawState(p); + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.setTextSize(tp.getTextSize() * relativeSize); + tp.baselineShift += (int) (tp.ascent() * relativeSize) / 4; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java b/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java new file mode 100644 index 0000000000000000000000000000000000000000..23d76efcd1cbc359c1577b73e1faae1e2a157d05 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util.task; + +import android.content.Context; +import android.content.DialogInterface.OnCancelListener; +import android.os.AsyncTask; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +import java.lang.ref.WeakReference; + +public abstract class ProgressDialogAsyncTask extends AsyncTask { + + private final WeakReference contextReference; + private ProgressDialog progress; + private final String title; + private final String message; + private boolean cancelable; + private OnCancelListener onCancelListener; + + public ProgressDialogAsyncTask(Context context, String title, String message) { + super(); + this.contextReference = new WeakReference<>(context); + this.title = title; + this.message = message; + } + + public void setCancelable(@Nullable OnCancelListener onCancelListener) { + this.cancelable = true; + this.onCancelListener = onCancelListener; + } + + @Override + protected void onPreExecute() { + final Context context = contextReference.get(); + if (context != null) { + progress = ProgressDialog.show(context, title, message, true, cancelable, onCancelListener); + } + } + + @Override + protected void onPostExecute(Result result) { + try { + if (progress != null) progress.dismiss(); + } catch(Exception e) { + e.printStackTrace(); + } + } + + protected Context getContext() { + return contextReference.get(); + } +} + diff --git a/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java b/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java new file mode 100644 index 0000000000000000000000000000000000000000..19e644a81935e6cb1a143da49130f43472e7313c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.util.task; + +import android.os.AsyncTask; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.google.android.material.snackbar.Snackbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.views.ProgressDialog; + +public abstract class SnackbarAsyncTask + extends AsyncTask + implements View.OnClickListener +{ + + private final View view; + private final String snackbarText; + private final String snackbarActionText; + private final int snackbarDuration; + private final boolean showProgress; + + private @Nullable Params reversibleParameter; + private @Nullable ProgressDialog progressDialog; + + public SnackbarAsyncTask(View view, + String snackbarText, + String snackbarActionText, + int snackbarDuration, + boolean showProgress) + { + this.view = view; + this.snackbarText = snackbarText; + this.snackbarActionText = snackbarActionText; + this.snackbarDuration = snackbarDuration; + this.showProgress = showProgress; + } + + @Override + protected void onPreExecute() { + if (this.showProgress) { + this.progressDialog = ProgressDialog.show(view.getContext(), + "", view.getContext().getString(R.string.one_moment), true, false); + } + else { + this.progressDialog = null; + } + } + + @SafeVarargs + @Override + protected final Void doInBackground(Params... params) { + this.reversibleParameter = params != null && params.length > 0 ?params[0] : null; + executeAction(reversibleParameter); + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (this.showProgress && this.progressDialog != null) { + this.progressDialog.dismiss(); + this.progressDialog = null; + } + + Snackbar.make(view, snackbarText, snackbarDuration) + .setAction(snackbarActionText, this) + .setActionTextColor(view.getResources().getColor(R.color.white)) + .show(); + } + + @Override + public void onClick(View v) { + new AsyncTask() { + @Override + protected void onPreExecute() { + if (showProgress) progressDialog = ProgressDialog.show(view.getContext(), "", "", true); + else progressDialog = null; + } + + @Override + protected Void doInBackground(Void... params) { + reverseAction(reversibleParameter); + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (showProgress && progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + protected abstract void executeAction(@Nullable Params parameter); + protected abstract void reverseAction(@Nullable Params parameter); + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/views/ConversationAdaptiveActionsToolbar.java b/src/main/java/org/thoughtcrime/securesms/util/views/ConversationAdaptiveActionsToolbar.java new file mode 100644 index 0000000000000000000000000000000000000000..df2ba39b47e9142690f13b8676eba2435c155adb --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/views/ConversationAdaptiveActionsToolbar.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * This class was pulled from Signal (AdaptiveActionsToolbar) and then adapted to the ConversationActivity. + */ +public class ConversationAdaptiveActionsToolbar extends Toolbar { + + private static final int NAVIGATION_DP = 56; + private static final int TITLE_DP = 48; // estimated, only a number (if >1 items are selected there is more room anyway as there are fewer options) + private static final int ACTION_VIEW_WIDTH_DP = 48; + private static final int OVERFLOW_VIEW_WIDTH_DP = 36; + + private static final int ID_ACTION_1 = R.id.menu_context_edit; + private static final int ID_ACTION_2 = R.id.menu_context_copy; + private static final int ID_ACTION_3 = R.id.menu_context_share; + private static final int ID_ACTION_4 = R.id.menu_context_forward; + private static final int ID_ACTION_5 = R.id.menu_context_delete_message; + + private final int maxShown; + + public ConversationAdaptiveActionsToolbar(@NonNull Context context) { + this(context, null); + } + + public ConversationAdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + } + + public ConversationAdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ConversationAdaptiveActionsToolbar); + + maxShown = array.getInteger(R.styleable.ConversationAdaptiveActionsToolbar_aat_max_shown, 100); + + array.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + adjustMenuActions(getMenu(), maxShown, getMeasuredWidth()); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + public static void adjustMenuActions(@NonNull Menu menu, int maxToShow, int toolbarWidthPx) { + int menuSize = 0; + + for (int i = 0; i < menu.size(); i++) { + if (menu.getItem(i).isVisible()) { + menuSize++; + } + } + + int widthAllowed = toolbarWidthPx - ViewUtil.dpToPx(NAVIGATION_DP + TITLE_DP); + int nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); + + if (nItemsToShow < menuSize) { + widthAllowed -= ViewUtil.dpToPx(OVERFLOW_VIEW_WIDTH_DP); + } + + nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); + + for (int i = 0; i < menu.size(); i++) { + MenuItem item = menu.getItem(i); + + boolean showAsAction = item.getItemId() == ID_ACTION_1 + || item.getItemId() == ID_ACTION_2 + || item.getItemId() == ID_ACTION_3 + || item.getItemId() == ID_ACTION_4 + || item.getItemId() == ID_ACTION_5; + + if (showAsAction && item.isVisible() && nItemsToShow > 0) { + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + nItemsToShow--; + } else { + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/views/ProgressDialog.java b/src/main/java/org/thoughtcrime/securesms/util/views/ProgressDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..583c0f893a57bd13c86e73972090f0dfafd2a583 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/views/ProgressDialog.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.util.views; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.PorterDuff; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +public class ProgressDialog extends AlertDialog { + + private boolean indeterminate; + private String message; + private TextView textView; + private ProgressBar progressBar; + + public ProgressDialog(@NonNull Context context) { + super(context); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + View dialogView = View.inflate(getContext(), R.layout.dialog_progress, null); + setView(dialogView); + super.onCreate(savedInstanceState); + } + + @Override + public void setMessage(CharSequence message) { + this.message = message.toString(); + if (textView != null) { + textView.setText(message); + } + } + + private boolean isButtonVisible(int which) { + Button button = getButton(which); + if (button==null) { + return false; + } + return button.getVisibility()==View.VISIBLE; + } + + @Override + public void show() { + super.show(); + + if (isButtonVisible(Dialog.BUTTON_POSITIVE) || isButtonVisible(Dialog.BUTTON_NEGATIVE) || isButtonVisible(Dialog.BUTTON_NEUTRAL)) { + findViewById(R.id.noButtonsSpacer).setVisibility(View.GONE); + } + + progressBar = findViewById(R.id.progressBar); + textView = findViewById(R.id.text); + setupProgressBar(); + setupTextView(); + } + + private void setupProgressBar() { + if (progressBar != null) { + progressBar.getIndeterminateDrawable() + .setColorFilter(ContextCompat.getColor(getContext(), R.color.def_accent), PorterDuff.Mode.SRC_IN); + progressBar.setIndeterminate(indeterminate); + } + } + + private void setupTextView() { + if (textView != null && message != null && !message.isEmpty()) { + textView.setText(message); + } + } + + private void setIndeterminate(boolean indeterminate) { + this.indeterminate = indeterminate; + if (progressBar != null) { + progressBar.setIndeterminate(indeterminate); + } + } + + // Source: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/ProgressDialog.java + public static ProgressDialog show(Context context, CharSequence title, + CharSequence message, boolean indeterminate) { + return show(context, title, message, indeterminate, false, null); + } + + // Source: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/ProgressDialog.java + public static ProgressDialog show(Context context, CharSequence title, + CharSequence message, boolean indeterminate, boolean cancelable) { + return show(context, title, message, indeterminate, cancelable, null); + } + + // Source: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/ProgressDialog.java + public static ProgressDialog show(Context context, CharSequence title, + CharSequence message, boolean indeterminate, + boolean cancelable, OnCancelListener cancelListener) { + ProgressDialog dialog = new ProgressDialog(context); + dialog.setTitle(title); + dialog.setMessage(message); + dialog.setIndeterminate(indeterminate); + dialog.setCancelable(cancelable); + dialog.setOnCancelListener(cancelListener); + if (cancelable) { + dialog.setCanceledOnTouchOutside(false); + dialog.setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(R.string.cancel), + ((dialog1, which) -> cancelListener.onCancel(dialog))); + } + dialog.show(); + return dialog; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java b/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java new file mode 100644 index 0000000000000000000000000000000000000000..7d964844461dd2f5b7ef7f9dfb8b63bd8f61be3f --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.util.views; + + +import android.view.ViewStub; + +import androidx.annotation.NonNull; + +public class Stub { + + private ViewStub viewStub; + private T view; + + public Stub(@NonNull ViewStub viewStub) { + this.viewStub = viewStub; + } + + public T get() { + if (view == null) { + view = (T)viewStub.inflate(); + viewStub = null; + } + + return view; + } + + public boolean resolved() { + return view != null; + } + +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java new file mode 100644 index 0000000000000000000000000000000000000000..4e776c26e7d20555e1e965f8e6b6181ef955ddfa --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2017 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.video; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.LoopingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; + +public class VideoPlayer extends FrameLayout { + + @Nullable private final PlayerView exoView; + + @Nullable private SimpleExoPlayer exoPlayer; + @Nullable private Window window; + + public VideoPlayer(Context context) { + this(context, null); + } + + public VideoPlayer(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.video_player, this); + + this.exoView = ViewUtil.findById(this, R.id.video_view); + } + + public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay) + { + setExoViewSource(videoSource, autoplay); + } + + public void pause() { + if (this.exoPlayer != null) { + this.exoPlayer.setPlayWhenReady(false); + } + } + + public void cleanup() { + if (this.exoPlayer != null) { + this.exoPlayer.release(); + } + } + + public void setWindow(@Nullable Window window) { + this.window = window; + } + + private void setExoViewSource(@NonNull VideoSlide videoSource, boolean autoplay) + { + BandwidthMeter bandwidthMeter = new DefaultBandwidthMeter.Builder(getContext()).build(); + TrackSelector trackSelector = new DefaultTrackSelector(getContext()); + LoadControl loadControl = new DefaultLoadControl(); + + exoPlayer = new SimpleExoPlayer.Builder(getContext()) + .setTrackSelector(trackSelector) + .setBandwidthMeter(bandwidthMeter) + .setLoadControl(loadControl) + .build(); + exoPlayer.addListener(new ExoPlayerListener(window)); + //noinspection ConstantConditions + exoView.setPlayer(exoPlayer); + + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(getContext(), "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(defaultDataSourceFactory); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); + + MediaSource mediaSource = new ProgressiveMediaSource.Factory(attachmentDataSourceFactory, extractorsFactory) + .createMediaSource(MediaItem.fromUri(videoSource.getUri())); + + exoPlayer.prepare(new LoopingMediaSource(mediaSource)); + exoPlayer.setPlayWhenReady(autoplay); + } + + private static class ExoPlayerListener implements Player.Listener { + private final Window window; + + ExoPlayerListener(Window window) { + this.window = window; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + switch(playbackState) { + case Player.STATE_IDLE: + case Player.STATE_BUFFERING: + case Player.STATE_ENDED: + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + break; + case Player.STATE_READY: + if (playWhenReady) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + break; + default: + break; + } + } + } + + +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java b/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java new file mode 100644 index 0000000000000000000000000000000000000000..af37ca8d071768419adec54e4c23bbc08e1be6bd --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.video.exo; + + +import android.net.Uri; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class AttachmentDataSource implements DataSource { + + private final DefaultDataSource defaultDataSource; + + private DataSource dataSource; + + public AttachmentDataSource(DefaultDataSource defaultDataSource) { + this.defaultDataSource = defaultDataSource; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + dataSource = defaultDataSource; + return dataSource.open(dataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + return dataSource.read(buffer, offset, readLength); + } + + @Override + public Uri getUri() { + return dataSource.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + dataSource.close(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java b/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..1638db7d49bc90161dde760021190190896f4016 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.video.exo; + + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +public class AttachmentDataSourceFactory implements DataSource.Factory { + + private final DefaultDataSourceFactory defaultDataSourceFactory; + + public AttachmentDataSourceFactory(@NonNull DefaultDataSourceFactory defaultDataSourceFactory) + { + this.defaultDataSourceFactory = defaultDataSourceFactory; + } + + @Override + public AttachmentDataSource createDataSource() { + return new AttachmentDataSource(defaultDataSourceFactory.createDataSource()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/InputSurface.java b/src/main/java/org/thoughtcrime/securesms/video/recode/InputSurface.java new file mode 100644 index 0000000000000000000000000000000000000000..07ef7fdc096adf7b8edd044be36ce5ff72ab1a2a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/InputSurface.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.view.Surface; + +@TargetApi(17) +public class InputSurface { + + private static final int EGL_RECORDABLE_ANDROID = 0x3142; + private static final int EGL_OPENGL_ES2_BIT = 4; + private EGLDisplay mEGLDisplay; + private EGLContext mEGLContext; + private EGLSurface mEGLSurface; + private Surface mSurface; + + public InputSurface(Surface surface) { + if (surface == null) { + throw new NullPointerException(); + } + mSurface = surface; + eglSetup(); + } + + private void eglSetup() { + mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { + throw new RuntimeException("unable to get EGL14 display"); + } + int[] version = new int[2]; + if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) { + mEGLDisplay = null; + throw new RuntimeException("unable to initialize EGL14"); + } + + int[] attribList = { + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RECORDABLE_ANDROID, 1, + EGL14.EGL_NONE + }; + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + if (!EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.length, + numConfigs, 0)) { + throw new RuntimeException("unable to find RGB888+recordable ES2 EGL config"); + } + + int[] attrib_list = { + EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, + EGL14.EGL_NONE + }; + + mEGLContext = EGL14.eglCreateContext(mEGLDisplay, configs[0], EGL14.EGL_NO_CONTEXT, attrib_list, 0); + checkEglError("eglCreateContext"); + if (mEGLContext == null) { + throw new RuntimeException("null context"); + } + + int[] surfaceAttribs = { + EGL14.EGL_NONE + }; + mEGLSurface = EGL14.eglCreateWindowSurface(mEGLDisplay, configs[0], mSurface, + surfaceAttribs, 0); + checkEglError("eglCreateWindowSurface"); + if (mEGLSurface == null) { + throw new RuntimeException("surface was null"); + } + } + + public void release() { + if (EGL14.eglGetCurrentContext().equals(mEGLContext)) { + EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); + } + EGL14.eglDestroySurface(mEGLDisplay, mEGLSurface); + EGL14.eglDestroyContext(mEGLDisplay, mEGLContext); + mSurface.release(); + mEGLDisplay = null; + mEGLContext = null; + mEGLSurface = null; + mSurface = null; + } + + public void makeCurrent() { + if (!EGL14.eglMakeCurrent(mEGLDisplay, mEGLSurface, mEGLSurface, mEGLContext)) { + throw new RuntimeException("eglMakeCurrent failed"); + } + } + + public boolean swapBuffers() { + return EGL14.eglSwapBuffers(mEGLDisplay, mEGLSurface); + } + + public Surface getSurface() { + return mSurface; + } + + public void setPresentationTime(long nsecs) { + EGLExt.eglPresentationTimeANDROID(mEGLDisplay, mEGLSurface, nsecs); + } + + private void checkEglError(String msg) { + boolean failed = false; + while (EGL14.eglGetError() != EGL14.EGL_SUCCESS) { + failed = true; + } + if (failed) { + throw new RuntimeException("EGL error encountered (see log)"); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/MP4Builder.java b/src/main/java/org/thoughtcrime/securesms/video/recode/MP4Builder.java new file mode 100644 index 0000000000000000000000000000000000000000..b5502928ae216e62c27c44fdef331ab5fabec58a --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/MP4Builder.java @@ -0,0 +1,437 @@ +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaFormat; + +import com.coremedia.iso.BoxParser; +import com.coremedia.iso.IsoFile; +import com.coremedia.iso.IsoTypeWriter; +import com.coremedia.iso.boxes.Box; +import com.coremedia.iso.boxes.Container; +import com.coremedia.iso.boxes.DataEntryUrlBox; +import com.coremedia.iso.boxes.DataInformationBox; +import com.coremedia.iso.boxes.DataReferenceBox; +import com.coremedia.iso.boxes.FileTypeBox; +import com.coremedia.iso.boxes.HandlerBox; +import com.coremedia.iso.boxes.MediaBox; +import com.coremedia.iso.boxes.MediaHeaderBox; +import com.coremedia.iso.boxes.MediaInformationBox; +import com.coremedia.iso.boxes.MovieBox; +import com.coremedia.iso.boxes.MovieHeaderBox; +import com.coremedia.iso.boxes.SampleSizeBox; +import com.coremedia.iso.boxes.SampleTableBox; +import com.coremedia.iso.boxes.SampleToChunkBox; +import com.coremedia.iso.boxes.StaticChunkOffsetBox; +import com.coremedia.iso.boxes.SyncSampleBox; +import com.coremedia.iso.boxes.TimeToSampleBox; +import com.coremedia.iso.boxes.TrackBox; +import com.coremedia.iso.boxes.TrackHeaderBox; +import com.googlecode.mp4parser.DataSource; +import com.googlecode.mp4parser.util.Matrix; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.WritableByteChannel; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; + +@TargetApi(16) +public class MP4Builder { + + private InterleaveChunkMdat mdat = null; + private Mp4Movie currentMp4Movie = null; + private FileOutputStream fos = null; + private FileChannel fc = null; + private long dataOffset = 0; + private long writedSinceLastMdat = 0; + private boolean writeNewMdat = true; + private final HashMap track2SampleSizes = new HashMap<>(); + private ByteBuffer sizeBuffer = null; + + public MP4Builder createMovie(Mp4Movie mp4Movie) throws Exception { + currentMp4Movie = mp4Movie; + + fos = new FileOutputStream(mp4Movie.getCacheFile()); + fc = fos.getChannel(); + + FileTypeBox fileTypeBox = createFileTypeBox(); + fileTypeBox.getBox(fc); + dataOffset += fileTypeBox.getSize(); + writedSinceLastMdat += dataOffset; + + mdat = new InterleaveChunkMdat(); + + sizeBuffer = ByteBuffer.allocateDirect(4); + + return this; + } + + private void flushCurrentMdat() throws Exception { + long oldPosition = fc.position(); + fc.position(mdat.getOffset()); + mdat.getBox(fc); + fc.position(oldPosition); + mdat.setDataOffset(0); + mdat.setContentSize(0); + fos.flush(); + } + + public boolean writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo, boolean isAudio) throws Exception { + if (writeNewMdat) { + mdat.setContentSize(0); + mdat.getBox(fc); + mdat.setDataOffset(dataOffset); + dataOffset += 16; + writedSinceLastMdat += 16; + writeNewMdat = false; + } + + mdat.setContentSize(mdat.getContentSize() + bufferInfo.size); + writedSinceLastMdat += bufferInfo.size; + + boolean flush = false; + if (writedSinceLastMdat >= 32 * 1024) { + flushCurrentMdat(); + writeNewMdat = true; + flush = true; + writedSinceLastMdat -= 32 * 1024; + } + + currentMp4Movie.addSample(trackIndex, dataOffset, bufferInfo); + byteBuf.position(bufferInfo.offset + (isAudio ? 0 : 4)); + byteBuf.limit(bufferInfo.offset + bufferInfo.size); + + if (!isAudio) { + sizeBuffer.position(0); + sizeBuffer.putInt(bufferInfo.size - 4); + sizeBuffer.position(0); + fc.write(sizeBuffer); + } + + fc.write(byteBuf); + dataOffset += bufferInfo.size; + + if (flush) { + fos.flush(); + } + return flush; + } + + public int addTrack(MediaFormat mediaFormat, boolean isAudio) throws Exception { + return currentMp4Movie.addTrack(mediaFormat, isAudio); + } + + public void finishMovie(boolean error) throws Exception { + if (mdat.getContentSize() != 0) { + flushCurrentMdat(); + } + + for (Track track : currentMp4Movie.getTracks()) { + List samples = track.getSamples(); + long[] sizes = new long[samples.size()]; + for (int i = 0; i < sizes.length; i++) { + sizes[i] = samples.get(i).getSize(); + } + track2SampleSizes.put(track, sizes); + } + + Box moov = createMovieBox(currentMp4Movie); + moov.getBox(fc); + fos.flush(); + + fc.close(); + fos.close(); + } + + protected FileTypeBox createFileTypeBox() { + LinkedList minorBrands = new LinkedList<>(); + minorBrands.add("isom"); + minorBrands.add("3gp4"); + return new FileTypeBox("isom", 0, minorBrands); + } + + private class InterleaveChunkMdat implements Box { + private Container parent; + private long contentSize = 1024 * 1024 * 1024; + private long dataOffset = 0; + + public Container getParent() { + return parent; + } + + public long getOffset() { + return dataOffset; + } + + public void setDataOffset(long offset) { + dataOffset = offset; + } + + public void setParent(Container parent) { + this.parent = parent; + } + + public void setContentSize(long contentSize) { + this.contentSize = contentSize; + } + + public long getContentSize() { + return contentSize; + } + + public String getType() { + return "mdat"; + } + + public long getSize() { + return 16 + contentSize; + } + + private boolean isSmallBox(long contentSize) { + return (contentSize + 8) < 4294967296L; + } + + @Override + public void parse(DataSource dataSource, ByteBuffer header, long contentSize, BoxParser boxParser) throws IOException { + + } + + public void getBox(WritableByteChannel writableByteChannel) throws IOException { + ByteBuffer bb = ByteBuffer.allocate(16); + long size = getSize(); + if (isSmallBox(size)) { + IsoTypeWriter.writeUInt32(bb, size); + } else { + IsoTypeWriter.writeUInt32(bb, 1); + } + bb.put(IsoFile.fourCCtoBytes("mdat")); + if (isSmallBox(size)) { + bb.put(new byte[8]); + } else { + IsoTypeWriter.writeUInt64(bb, size); + } + bb.rewind(); + writableByteChannel.write(bb); + } + } + + public static long gcd(long a, long b) { + if (b == 0) { + return a; + } + return gcd(b, a % b); + } + + public long getTimescale(Mp4Movie mp4Movie) { + long timescale = 0; + if (!mp4Movie.getTracks().isEmpty()) { + timescale = mp4Movie.getTracks().iterator().next().getTimeScale(); + } + for (Track track : mp4Movie.getTracks()) { + timescale = gcd(track.getTimeScale(), timescale); + } + return timescale; + } + + protected MovieBox createMovieBox(Mp4Movie movie) { + MovieBox movieBox = new MovieBox(); + MovieHeaderBox mvhd = new MovieHeaderBox(); + + mvhd.setCreationTime(new Date()); + mvhd.setModificationTime(new Date()); + mvhd.setMatrix(Matrix.ROTATE_0); + long movieTimeScale = getTimescale(movie); + long duration = 0; + + for (Track track : movie.getTracks()) { + long tracksDuration = track.getDuration() * movieTimeScale / track.getTimeScale(); + if (tracksDuration > duration) { + duration = tracksDuration; + } + } + + mvhd.setDuration(duration); + mvhd.setTimescale(movieTimeScale); + mvhd.setNextTrackId(movie.getTracks().size() + 1); + + movieBox.addBox(mvhd); + for (Track track : movie.getTracks()) { + movieBox.addBox(createTrackBox(track, movie)); + } + return movieBox; + } + + protected TrackBox createTrackBox(Track track, Mp4Movie movie) { + TrackBox trackBox = new TrackBox(); + TrackHeaderBox tkhd = new TrackHeaderBox(); + + tkhd.setEnabled(true); + tkhd.setInMovie(true); + tkhd.setInPreview(true); + if (track.isAudio()) { + tkhd.setMatrix(Matrix.ROTATE_0); + } else { + tkhd.setMatrix(movie.getMatrix()); + } + tkhd.setAlternateGroup(0); + tkhd.setCreationTime(track.getCreationTime()); + tkhd.setDuration(track.getDuration() * getTimescale(movie) / track.getTimeScale()); + tkhd.setHeight(track.getHeight()); + tkhd.setWidth(track.getWidth()); + tkhd.setLayer(0); + tkhd.setModificationTime(new Date()); + tkhd.setTrackId(track.getTrackId() + 1); + tkhd.setVolume(track.getVolume()); + + trackBox.addBox(tkhd); + + MediaBox mdia = new MediaBox(); + trackBox.addBox(mdia); + MediaHeaderBox mdhd = new MediaHeaderBox(); + mdhd.setCreationTime(track.getCreationTime()); + mdhd.setDuration(track.getDuration()); + mdhd.setTimescale(track.getTimeScale()); + mdhd.setLanguage("eng"); + mdia.addBox(mdhd); + HandlerBox hdlr = new HandlerBox(); + hdlr.setName(track.isAudio() ? "SoundHandle" : "VideoHandle"); + hdlr.setHandlerType(track.getHandler()); + + mdia.addBox(hdlr); + + MediaInformationBox minf = new MediaInformationBox(); + minf.addBox(track.getMediaHeaderBox()); + + DataInformationBox dinf = new DataInformationBox(); + DataReferenceBox dref = new DataReferenceBox(); + dinf.addBox(dref); + DataEntryUrlBox url = new DataEntryUrlBox(); + url.setFlags(1); + dref.addBox(url); + minf.addBox(dinf); + + Box stbl = createStbl(track); + minf.addBox(stbl); + mdia.addBox(minf); + + return trackBox; + } + + protected Box createStbl(Track track) { + SampleTableBox stbl = new SampleTableBox(); + + createStsd(track, stbl); + createStts(track, stbl); + createStss(track, stbl); + createStsc(track, stbl); + createStsz(track, stbl); + createStco(track, stbl); + + return stbl; + } + + protected void createStsd(Track track, SampleTableBox stbl) { + stbl.addBox(track.getSampleDescriptionBox()); + } + + protected void createStts(Track track, SampleTableBox stbl) { + TimeToSampleBox.Entry lastEntry = null; + List entries = new ArrayList<>(); + + for (long delta : track.getSampleDurations()) { + if (lastEntry != null && lastEntry.getDelta() == delta) { + lastEntry.setCount(lastEntry.getCount() + 1); + } else { + lastEntry = new TimeToSampleBox.Entry(1, delta); + entries.add(lastEntry); + } + } + TimeToSampleBox stts = new TimeToSampleBox(); + stts.setEntries(entries); + stbl.addBox(stts); + } + + protected void createStss(Track track, SampleTableBox stbl) { + long[] syncSamples = track.getSyncSamples(); + if (syncSamples != null && syncSamples.length > 0) { + SyncSampleBox stss = new SyncSampleBox(); + stss.setSampleNumber(syncSamples); + stbl.addBox(stss); + } + } + + protected void createStsc(Track track, SampleTableBox stbl) { + SampleToChunkBox stsc = new SampleToChunkBox(); + stsc.setEntries(new LinkedList()); + + long lastOffset; + int lastChunkNumber = 1; + int lastSampleCount = 0; + + int previousWritedChunkCount = -1; + + int samplesCount = track.getSamples().size(); + for (int a = 0; a < samplesCount; a++) { + Sample sample = track.getSamples().get(a); + long offset = sample.getOffset(); + long size = sample.getSize(); + + lastOffset = offset + size; + lastSampleCount++; + + boolean write = false; + if (a != samplesCount - 1) { + Sample nextSample = track.getSamples().get(a + 1); + if (lastOffset != nextSample.getOffset()) { + write = true; + } + } else { + write = true; + } + if (write) { + if (previousWritedChunkCount != lastSampleCount) { + stsc.getEntries().add(new SampleToChunkBox.Entry(lastChunkNumber, lastSampleCount, 1)); + previousWritedChunkCount = lastSampleCount; + } + lastSampleCount = 0; + lastChunkNumber++; + } + } + stbl.addBox(stsc); + } + + protected void createStsz(Track track, SampleTableBox stbl) { + SampleSizeBox stsz = new SampleSizeBox(); + stsz.setSampleSizes(track2SampleSizes.get(track)); + stbl.addBox(stsz); + } + + protected void createStco(Track track, SampleTableBox stbl) { + ArrayList chunksOffsets = new ArrayList<>(); + long lastOffset = -1; + for (Sample sample : track.getSamples()) { + long offset = sample.getOffset(); + if (lastOffset != -1 && lastOffset != offset) { + lastOffset = -1; + } + if (lastOffset == -1) { + chunksOffsets.add(offset); + } + lastOffset = offset + sample.getSize(); + } + long[] chunkOffsetsLong = new long[chunksOffsets.size()]; + for (int a = 0; a < chunksOffsets.size(); a++) { + chunkOffsetsLong[a] = chunksOffsets.get(a); + } + + StaticChunkOffsetBox stco = new StaticChunkOffsetBox(); + stco.setChunkOffsets(chunkOffsetsLong); + stbl.addBox(stco); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/Mp4Movie.java b/src/main/java/org/thoughtcrime/securesms/video/recode/Mp4Movie.java new file mode 100644 index 0000000000000000000000000000000000000000..6d2a9b0018d270f71e9c11c5c7bcdef1987000a1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/Mp4Movie.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaFormat; + +import com.googlecode.mp4parser.util.Matrix; + +import java.io.File; +import java.util.ArrayList; + +@TargetApi(16) +public class Mp4Movie { + private Matrix matrix = Matrix.ROTATE_0; + private final ArrayList tracks = new ArrayList<>(); + private File cacheFile; + private int width; + private int height; + + public Matrix getMatrix() { + return matrix; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public void setCacheFile(File file) { + cacheFile = file; + } + + public void setRotation(int angle) { + if (angle == 0) { + matrix = Matrix.ROTATE_0; + } else if (angle == 90) { + matrix = Matrix.ROTATE_90; + } else if (angle == 180) { + matrix = Matrix.ROTATE_180; + } else if (angle == 270) { + matrix = Matrix.ROTATE_270; + } + } + + public void setSize(int w, int h) { + width = w; + height = h; + } + + public ArrayList getTracks() { + return tracks; + } + + public File getCacheFile() { + return cacheFile; + } + + public void addSample(int trackIndex, long offset, MediaCodec.BufferInfo bufferInfo) throws Exception { + if (trackIndex < 0 || trackIndex >= tracks.size()) { + return; + } + Track track = tracks.get(trackIndex); + track.addSample(offset, bufferInfo); + } + + public int addTrack(MediaFormat mediaFormat, boolean isAudio) throws Exception { + tracks.add(new Track(tracks.size(), mediaFormat, isAudio)); + return tracks.size() - 1; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/OutputSurface.java b/src/main/java/org/thoughtcrime/securesms/video/recode/OutputSurface.java new file mode 100644 index 0000000000000000000000000000000000000000..dd6937288f1f58abe7039e6c5f070f5fc2fa5bb2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/OutputSurface.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.graphics.SurfaceTexture; +import android.opengl.GLES20; +import android.view.Surface; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; +import javax.microedition.khronos.egl.EGLSurface; + +@TargetApi(16) +public class OutputSurface implements SurfaceTexture.OnFrameAvailableListener { + + private static final int EGL_OPENGL_ES2_BIT = 4; + private static final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; + private EGL10 mEGL; + private EGLDisplay mEGLDisplay = null; + private EGLContext mEGLContext = null; + private EGLSurface mEGLSurface = null; + private SurfaceTexture mSurfaceTexture; + private Surface mSurface; + private final Object mFrameSyncObject = new Object(); + private boolean mFrameAvailable; + private TextureRenderer mTextureRender; + private int mWidth; + private int mHeight; + private int rotateRender = 0; + private ByteBuffer mPixelBuf; + + public OutputSurface(int width, int height, int rotate) { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException(); + } + mWidth = width; + mHeight = height; + rotateRender = rotate; + mPixelBuf = ByteBuffer.allocateDirect(mWidth * mHeight * 4); + mPixelBuf.order(ByteOrder.LITTLE_ENDIAN); + eglSetup(width, height); + makeCurrent(); + setup(); + } + + public OutputSurface() { + setup(); + } + + private void setup() { + mTextureRender = new TextureRenderer(rotateRender); + mTextureRender.surfaceCreated(); + mSurfaceTexture = new SurfaceTexture(mTextureRender.getTextureId()); + mSurfaceTexture.setOnFrameAvailableListener(this); + mSurface = new Surface(mSurfaceTexture); + } + + private void eglSetup(int width, int height) { + mEGL = (EGL10) EGLContext.getEGL(); + mEGLDisplay = mEGL.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + + if (mEGLDisplay == EGL10.EGL_NO_DISPLAY) { + throw new RuntimeException("unable to get EGL10 display"); + } + + if (!mEGL.eglInitialize(mEGLDisplay, null)) { + mEGLDisplay = null; + throw new RuntimeException("unable to initialize EGL10"); + } + + int[] attribList = { + EGL10.EGL_RED_SIZE, 8, + EGL10.EGL_GREEN_SIZE, 8, + EGL10.EGL_BLUE_SIZE, 8, + EGL10.EGL_ALPHA_SIZE, 8, + EGL10.EGL_SURFACE_TYPE, EGL10.EGL_PBUFFER_BIT, + EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL10.EGL_NONE + }; + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + if (!mEGL.eglChooseConfig(mEGLDisplay, attribList, configs, configs.length, numConfigs)) { + throw new RuntimeException("unable to find RGB888+pbuffer EGL config"); + } + int[] attrib_list = { + EGL_CONTEXT_CLIENT_VERSION, 2, + EGL10.EGL_NONE + }; + mEGLContext = mEGL.eglCreateContext(mEGLDisplay, configs[0], EGL10.EGL_NO_CONTEXT, attrib_list); + checkEglError("eglCreateContext"); + if (mEGLContext == null) { + throw new RuntimeException("null context"); + } + int[] surfaceAttribs = { + EGL10.EGL_WIDTH, width, + EGL10.EGL_HEIGHT, height, + EGL10.EGL_NONE + }; + mEGLSurface = mEGL.eglCreatePbufferSurface(mEGLDisplay, configs[0], surfaceAttribs); + checkEglError("eglCreatePbufferSurface"); + if (mEGLSurface == null) { + throw new RuntimeException("surface was null"); + } + } + + public void release() { + if (mEGL != null) { + if (mEGL.eglGetCurrentContext().equals(mEGLContext)) { + mEGL.eglMakeCurrent(mEGLDisplay, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT); + } + mEGL.eglDestroySurface(mEGLDisplay, mEGLSurface); + mEGL.eglDestroyContext(mEGLDisplay, mEGLContext); + } + mSurface.release(); + mEGLDisplay = null; + mEGLContext = null; + mEGLSurface = null; + mEGL = null; + mTextureRender = null; + mSurface = null; + mSurfaceTexture = null; + } + + public void makeCurrent() { + if (mEGL == null) { + throw new RuntimeException("not configured for makeCurrent"); + } + checkEglError("before makeCurrent"); + if (!mEGL.eglMakeCurrent(mEGLDisplay, mEGLSurface, mEGLSurface, mEGLContext)) { + throw new RuntimeException("eglMakeCurrent failed"); + } + } + + public Surface getSurface() { + return mSurface; + } + + public void changeFragmentShader(String fragmentShader) { + mTextureRender.changeFragmentShader(fragmentShader); + } + + public void awaitNewImage() { + final int TIMEOUT_MS = 2500; + synchronized (mFrameSyncObject) { + while (!mFrameAvailable) { + try { + mFrameSyncObject.wait(TIMEOUT_MS); + if (!mFrameAvailable) { + throw new RuntimeException("Surface frame wait timed out"); + } + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } + } + mFrameAvailable = false; + } + mTextureRender.checkGlError("before updateTexImage"); + mSurfaceTexture.updateTexImage(); + } + + public void drawImage(boolean invert) { + mTextureRender.drawFrame(mSurfaceTexture, invert); + } + + @Override + public void onFrameAvailable(SurfaceTexture st) { + synchronized (mFrameSyncObject) { + if (mFrameAvailable) { + throw new RuntimeException("mFrameAvailable already set, frame could be dropped"); + } + mFrameAvailable = true; + mFrameSyncObject.notifyAll(); + } + } + + public ByteBuffer getFrame() { + mPixelBuf.rewind(); + GLES20.glReadPixels(0, 0, mWidth, mHeight, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, mPixelBuf); + return mPixelBuf; + } + + private void checkEglError(String msg) { + if (mEGL.eglGetError() != EGL10.EGL_SUCCESS) { + throw new RuntimeException("EGL error encountered (see log)"); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/Sample.java b/src/main/java/org/thoughtcrime/securesms/video/recode/Sample.java new file mode 100644 index 0000000000000000000000000000000000000000..9b1b3da1de5980fcddeea175c0e8303fa8b84ce4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/Sample.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.video.recode; + +public class Sample { + private long offset = 0; + private long size = 0; + + public Sample(long offset, long size) { + this.offset = offset; + this.size = size; + } + + public long getOffset() { + return offset; + } + + public long getSize() { + return size; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/TextureRenderer.java b/src/main/java/org/thoughtcrime/securesms/video/recode/TextureRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..03c98dc6aea4be40cda4dd923403f290ddb2f611 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/TextureRenderer.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.graphics.SurfaceTexture; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.opengl.Matrix; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +@TargetApi(16) +public class TextureRenderer { + + private static final int FLOAT_SIZE_BYTES = 4; + private static final int TRIANGLE_VERTICES_DATA_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES; + private static final int TRIANGLE_VERTICES_DATA_POS_OFFSET = 0; + private static final int TRIANGLE_VERTICES_DATA_UV_OFFSET = 3; + private static final float[] mTriangleVerticesData = { + -1.0f, -1.0f, 0, 0.f, 0.f, + 1.0f, -1.0f, 0, 1.f, 0.f, + -1.0f, 1.0f, 0, 0.f, 1.f, + 1.0f, 1.0f, 0, 1.f, 1.f, + }; + private final FloatBuffer mTriangleVertices; + + private static final String VERTEX_SHADER = + "uniform mat4 uMVPMatrix;\n" + + "uniform mat4 uSTMatrix;\n" + + "attribute vec4 aPosition;\n" + + "attribute vec4 aTextureCoord;\n" + + "varying vec2 vTextureCoord;\n" + + "void main() {\n" + + " gl_Position = uMVPMatrix * aPosition;\n" + + " vTextureCoord = (uSTMatrix * aTextureCoord).xy;\n" + + "}\n"; + + private static final String FRAGMENT_SHADER = + "#extension GL_OES_EGL_image_external : require\n" + + "precision mediump float;\n" + + "varying vec2 vTextureCoord;\n" + + "uniform samplerExternalOES sTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(sTexture, vTextureCoord);\n" + + "}\n"; + + private final float[] mMVPMatrix = new float[16]; + private final float[] mSTMatrix = new float[16]; + private int mProgram; + private int mTextureID = -12345; + private int muMVPMatrixHandle; + private int muSTMatrixHandle; + private int maPositionHandle; + private int maTextureHandle; + private int rotationAngle = 0; + + public TextureRenderer(int rotation) { + rotationAngle = rotation; + mTriangleVertices = ByteBuffer.allocateDirect(mTriangleVerticesData.length * FLOAT_SIZE_BYTES).order(ByteOrder.nativeOrder()).asFloatBuffer(); + mTriangleVertices.put(mTriangleVerticesData).position(0); + Matrix.setIdentityM(mSTMatrix, 0); + } + + public int getTextureId() { + return mTextureID; + } + + public void drawFrame(SurfaceTexture st, boolean invert) { + checkGlError("onDrawFrame start"); + st.getTransformMatrix(mSTMatrix); + + if (invert) { + mSTMatrix[5] = -mSTMatrix[5]; + mSTMatrix[13] = 1.0f - mSTMatrix[13]; + } + + GLES20.glUseProgram(mProgram); + checkGlError("glUseProgram"); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureID); + mTriangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET); + GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices); + checkGlError("glVertexAttribPointer maPosition"); + GLES20.glEnableVertexAttribArray(maPositionHandle); + checkGlError("glEnableVertexAttribArray maPositionHandle"); + mTriangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET); + GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices); + checkGlError("glVertexAttribPointer maTextureHandle"); + GLES20.glEnableVertexAttribArray(maTextureHandle); + checkGlError("glEnableVertexAttribArray maTextureHandle"); + GLES20.glUniformMatrix4fv(muSTMatrixHandle, 1, false, mSTMatrix, 0); + GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, mMVPMatrix, 0); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + checkGlError("glDrawArrays"); + GLES20.glFinish(); + } + + public void surfaceCreated() { + mProgram = createProgram(VERTEX_SHADER, FRAGMENT_SHADER); + if (mProgram == 0) { + throw new RuntimeException("failed creating program"); + } + maPositionHandle = GLES20.glGetAttribLocation(mProgram, "aPosition"); + checkGlError("glGetAttribLocation aPosition"); + if (maPositionHandle == -1) { + throw new RuntimeException("Could not get attrib location for aPosition"); + } + maTextureHandle = GLES20.glGetAttribLocation(mProgram, "aTextureCoord"); + checkGlError("glGetAttribLocation aTextureCoord"); + if (maTextureHandle == -1) { + throw new RuntimeException("Could not get attrib location for aTextureCoord"); + } + muMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix"); + checkGlError("glGetUniformLocation uMVPMatrix"); + if (muMVPMatrixHandle == -1) { + throw new RuntimeException("Could not get attrib location for uMVPMatrix"); + } + muSTMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uSTMatrix"); + checkGlError("glGetUniformLocation uSTMatrix"); + if (muSTMatrixHandle == -1) { + throw new RuntimeException("Could not get attrib location for uSTMatrix"); + } + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + mTextureID = textures[0]; + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureID); + checkGlError("glBindTexture mTextureID"); + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + checkGlError("glTexParameter"); + + Matrix.setIdentityM(mMVPMatrix, 0); + if (rotationAngle != 0) { + Matrix.rotateM(mMVPMatrix, 0, rotationAngle, 0, 0, 1); + } + } + + public void changeFragmentShader(String fragmentShader) { + GLES20.glDeleteProgram(mProgram); + mProgram = createProgram(VERTEX_SHADER, fragmentShader); + if (mProgram == 0) { + throw new RuntimeException("failed creating program"); + } + } + + private int loadShader(int shaderType, String source) { + int shader = GLES20.glCreateShader(shaderType); + checkGlError("glCreateShader type=" + shaderType); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] compiled = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); + if (compiled[0] == 0) { + GLES20.glDeleteShader(shader); + shader = 0; + } + return shader; + } + + private int createProgram(String vertexSource, String fragmentSource) { + int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource); + if (vertexShader == 0) { + return 0; + } + int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + if (pixelShader == 0) { + return 0; + } + int program = GLES20.glCreateProgram(); + checkGlError("glCreateProgram"); + if (program == 0) { + return 0; + } + GLES20.glAttachShader(program, vertexShader); + checkGlError("glAttachShader"); + GLES20.glAttachShader(program, pixelShader); + checkGlError("glAttachShader"); + GLES20.glLinkProgram(program); + int[] linkStatus = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); + if (linkStatus[0] != GLES20.GL_TRUE) { + GLES20.glDeleteProgram(program); + program = 0; + } + return program; + } + + public void checkGlError(String op) { + int error; + if ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) { + throw new RuntimeException(op + ": glError " + error); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/Track.java b/src/main/java/org/thoughtcrime/securesms/video/recode/Track.java new file mode 100644 index 0000000000000000000000000000000000000000..523208690873fe1a7ab4bc08d885648286e9deac --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/Track.java @@ -0,0 +1,256 @@ +package org.thoughtcrime.securesms.video.recode; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaFormat; + +import com.coremedia.iso.boxes.AbstractMediaHeaderBox; +import com.coremedia.iso.boxes.SampleDescriptionBox; +import com.coremedia.iso.boxes.SoundMediaHeaderBox; +import com.coremedia.iso.boxes.VideoMediaHeaderBox; +import com.coremedia.iso.boxes.sampleentry.AudioSampleEntry; +import com.coremedia.iso.boxes.sampleentry.VisualSampleEntry; +import com.googlecode.mp4parser.boxes.mp4.ESDescriptorBox; +import com.googlecode.mp4parser.boxes.mp4.objectdescriptors.AudioSpecificConfig; +import com.googlecode.mp4parser.boxes.mp4.objectdescriptors.DecoderConfigDescriptor; +import com.googlecode.mp4parser.boxes.mp4.objectdescriptors.ESDescriptor; +import com.googlecode.mp4parser.boxes.mp4.objectdescriptors.SLConfigDescriptor; +import com.mp4parser.iso14496.part15.AvcConfigurationBox; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; + +@TargetApi(16) +public class Track { + private long trackId = 0; + private final ArrayList samples = new ArrayList<>(); + private long duration = 0; + private String handler; + private AbstractMediaHeaderBox headerBox = null; + private SampleDescriptionBox sampleDescriptionBox = null; + private LinkedList syncSamples = null; + private int timeScale; + private final Date creationTime = new Date(); + private int height; + private int width; + private float volume = 0; + private final ArrayList sampleDurations = new ArrayList<>(); + private boolean isAudio = false; + private static final Map samplingFrequencyIndexMap = new HashMap<>(); + private long lastPresentationTimeUs = 0; + private boolean first = true; + + static { + samplingFrequencyIndexMap.put(96000, 0x0); + samplingFrequencyIndexMap.put(88200, 0x1); + samplingFrequencyIndexMap.put(64000, 0x2); + samplingFrequencyIndexMap.put(48000, 0x3); + samplingFrequencyIndexMap.put(44100, 0x4); + samplingFrequencyIndexMap.put(32000, 0x5); + samplingFrequencyIndexMap.put(24000, 0x6); + samplingFrequencyIndexMap.put(22050, 0x7); + samplingFrequencyIndexMap.put(16000, 0x8); + samplingFrequencyIndexMap.put(12000, 0x9); + samplingFrequencyIndexMap.put(11025, 0xa); + samplingFrequencyIndexMap.put(8000, 0xb); + } + + public Track(int id, MediaFormat format, boolean audio) throws Exception { + trackId = id; + isAudio = audio; + if (!isAudio) { + sampleDurations.add((long) 3015); + duration = 3015; + width = format.getInteger(MediaFormat.KEY_WIDTH); + height = format.getInteger(MediaFormat.KEY_HEIGHT); + timeScale = 90000; + syncSamples = new LinkedList<>(); + handler = "vide"; + headerBox = new VideoMediaHeaderBox(); + sampleDescriptionBox = new SampleDescriptionBox(); + String mime = format.getString(MediaFormat.KEY_MIME); + if (mime.equals("video/avc")) { + VisualSampleEntry visualSampleEntry = new VisualSampleEntry("avc1"); + visualSampleEntry.setDataReferenceIndex(1); + visualSampleEntry.setDepth(24); + visualSampleEntry.setFrameCount(1); + visualSampleEntry.setHorizresolution(72); + visualSampleEntry.setVertresolution(72); + visualSampleEntry.setWidth(width); + visualSampleEntry.setHeight(height); + + AvcConfigurationBox avcConfigurationBox = new AvcConfigurationBox(); + + if (format.getByteBuffer("csd-0") != null) { + ArrayList spsArray = new ArrayList<>(); + ByteBuffer spsBuff = format.getByteBuffer("csd-0"); + spsBuff.position(4); + byte[] spsBytes = new byte[spsBuff.remaining()]; + spsBuff.get(spsBytes); + spsArray.add(spsBytes); + + ArrayList ppsArray = new ArrayList<>(); + ByteBuffer ppsBuff = format.getByteBuffer("csd-1"); + ppsBuff.position(4); + byte[] ppsBytes = new byte[ppsBuff.remaining()]; + ppsBuff.get(ppsBytes); + ppsArray.add(ppsBytes); + avcConfigurationBox.setSequenceParameterSets(spsArray); + avcConfigurationBox.setPictureParameterSets(ppsArray); + } + + avcConfigurationBox.setAvcLevelIndication(13); + avcConfigurationBox.setAvcProfileIndication(100); + avcConfigurationBox.setBitDepthLumaMinus8(-1); + avcConfigurationBox.setBitDepthChromaMinus8(-1); + avcConfigurationBox.setChromaFormat(-1); + avcConfigurationBox.setConfigurationVersion(1); + avcConfigurationBox.setLengthSizeMinusOne(3); + avcConfigurationBox.setProfileCompatibility(0); + + visualSampleEntry.addBox(avcConfigurationBox); + sampleDescriptionBox.addBox(visualSampleEntry); + } else if (mime.equals("video/mp4v")) { + VisualSampleEntry visualSampleEntry = new VisualSampleEntry("mp4v"); + visualSampleEntry.setDataReferenceIndex(1); + visualSampleEntry.setDepth(24); + visualSampleEntry.setFrameCount(1); + visualSampleEntry.setHorizresolution(72); + visualSampleEntry.setVertresolution(72); + visualSampleEntry.setWidth(width); + visualSampleEntry.setHeight(height); + + sampleDescriptionBox.addBox(visualSampleEntry); + } + } else { + sampleDurations.add((long) 1024); + duration = 1024; + volume = 1; + timeScale = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); + handler = "soun"; + headerBox = new SoundMediaHeaderBox(); + sampleDescriptionBox = new SampleDescriptionBox(); + AudioSampleEntry audioSampleEntry = new AudioSampleEntry("mp4a"); + audioSampleEntry.setChannelCount(format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + audioSampleEntry.setSampleRate(format.getInteger(MediaFormat.KEY_SAMPLE_RATE)); + audioSampleEntry.setDataReferenceIndex(1); + audioSampleEntry.setSampleSize(16); + + ESDescriptorBox esds = new ESDescriptorBox(); + ESDescriptor descriptor = new ESDescriptor(); + descriptor.setEsId(0); + + SLConfigDescriptor slConfigDescriptor = new SLConfigDescriptor(); + slConfigDescriptor.setPredefined(2); + descriptor.setSlConfigDescriptor(slConfigDescriptor); + + DecoderConfigDescriptor decoderConfigDescriptor = new DecoderConfigDescriptor(); + decoderConfigDescriptor.setObjectTypeIndication(0x40); + decoderConfigDescriptor.setStreamType(5); + decoderConfigDescriptor.setBufferSizeDB(1536); + decoderConfigDescriptor.setMaxBitRate(96000); + decoderConfigDescriptor.setAvgBitRate(96000); + + AudioSpecificConfig audioSpecificConfig = new AudioSpecificConfig(); + audioSpecificConfig.setAudioObjectType(2); + audioSpecificConfig.setSamplingFrequencyIndex(samplingFrequencyIndexMap.get((int) audioSampleEntry.getSampleRate())); + audioSpecificConfig.setChannelConfiguration(audioSampleEntry.getChannelCount()); + decoderConfigDescriptor.setAudioSpecificInfo(audioSpecificConfig); + + descriptor.setDecoderConfigDescriptor(decoderConfigDescriptor); + + ByteBuffer data = descriptor.serialize(); + esds.setEsDescriptor(descriptor); + esds.setData(data); + audioSampleEntry.addBox(esds); + sampleDescriptionBox.addBox(audioSampleEntry); + } + } + + public long getTrackId() { + return trackId; + } + + public void addSample(long offset, MediaCodec.BufferInfo bufferInfo) { + long delta = bufferInfo.presentationTimeUs - lastPresentationTimeUs; + if (delta < 0) { + return; + } + boolean isSyncFrame = !isAudio && (bufferInfo.flags & MediaCodec.BUFFER_FLAG_SYNC_FRAME) != 0; + samples.add(new Sample(offset, bufferInfo.size)); + if (syncSamples != null && isSyncFrame) { + syncSamples.add(samples.size()); + } + + delta = (delta * timeScale + 500000L) / 1000000L; + lastPresentationTimeUs = bufferInfo.presentationTimeUs; + if (!first) { + sampleDurations.add(sampleDurations.size() - 1, delta); + duration += delta; + } + first = false; + } + + public ArrayList getSamples() { + return samples; + } + + public long getDuration() { + return duration; + } + + public String getHandler() { + return handler; + } + + public AbstractMediaHeaderBox getMediaHeaderBox() { + return headerBox; + } + + public SampleDescriptionBox getSampleDescriptionBox() { + return sampleDescriptionBox; + } + + public long[] getSyncSamples() { + if (syncSamples == null || syncSamples.isEmpty()) { + return null; + } + long[] returns = new long[syncSamples.size()]; + for (int i = 0; i < syncSamples.size(); i++) { + returns[i] = syncSamples.get(i); + } + return returns; + } + + public int getTimeScale() { + return timeScale; + } + + public Date getCreationTime() { + return creationTime; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public float getVolume() { + return volume; + } + + public ArrayList getSampleDurations() { + return sampleDurations; + } + + public boolean isAudio() { + return isAudio; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/video/recode/VideoRecoder.java b/src/main/java/org/thoughtcrime/securesms/video/recode/VideoRecoder.java new file mode 100644 index 0000000000000000000000000000000000000000..a8b1cbd65f552296f1a5788bab109deaccf15ba8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/video/recode/VideoRecoder.java @@ -0,0 +1,658 @@ +package org.thoughtcrime.securesms.video.recode; + +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.util.Log; + +import androidx.appcompat.app.AlertDialog; + +import com.b44t.messenger.DcMsg; +import com.coremedia.iso.IsoFile; +import com.coremedia.iso.boxes.Box; +import com.coremedia.iso.boxes.MediaBox; +import com.coremedia.iso.boxes.MediaHeaderBox; +import com.coremedia.iso.boxes.SampleSizeBox; +import com.coremedia.iso.boxes.TrackBox; +import com.coremedia.iso.boxes.TrackHeaderBox; +import com.googlecode.mp4parser.util.Matrix; +import com.googlecode.mp4parser.util.Path; + +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Prefs; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.nio.ByteBuffer; +import java.util.List; + +public class VideoRecoder { + + private static final String TAG = VideoRecoder.class.getSimpleName(); + + private final static String MIME_TYPE = "video/avc"; + private final boolean cancelCurrentVideoConversion = false; + private final Object videoConvertSync = new Object(); + + private void checkConversionCanceled() throws Exception { + boolean cancelConversion; + synchronized (videoConvertSync) { + cancelConversion = cancelCurrentVideoConversion; + } + if (cancelConversion) { + throw new RuntimeException("canceled conversion"); + } + } + + private int selectTrack(MediaExtractor extractor, boolean audio) { + int numTracks = extractor.getTrackCount(); + for (int i = 0; i < numTracks; i++) { + MediaFormat format = extractor.getTrackFormat(i); + String mime = format.getString(MediaFormat.KEY_MIME); + if (audio) { + if (mime.startsWith("audio/")) { + return i; + } + } else { + if (mime.startsWith("video/")) { + return i; + } + } + } + return -5; + } + + private long readAndWriteTrack(MediaExtractor extractor, MP4Builder mediaMuxer, MediaCodec.BufferInfo info, long start, long end, File file, boolean isAudio) throws Exception { + int trackIndex = selectTrack(extractor, isAudio); + if (trackIndex >= 0) { + extractor.selectTrack(trackIndex); + MediaFormat trackFormat = extractor.getTrackFormat(trackIndex); + int muxerTrackIndex = mediaMuxer.addTrack(trackFormat, isAudio); + int maxBufferSize = trackFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE); + boolean inputDone = false; + if (start > 0) { + extractor.seekTo(start, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + } else { + extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + } + ByteBuffer buffer = ByteBuffer.allocateDirect(maxBufferSize); + long startTime = -1; + + checkConversionCanceled(); + long lastTimestamp = -100; + + while (!inputDone) { + checkConversionCanceled(); + + boolean eof = false; + int index = extractor.getSampleTrackIndex(); + if (index == trackIndex) { + info.size = extractor.readSampleData(buffer, 0); + if (info.size >= 0) { + info.presentationTimeUs = extractor.getSampleTime(); + } else { + info.size = 0; + eof = true; + } + + if (info.size > 0 && !eof) { + if (start > 0 && startTime == -1) { + startTime = info.presentationTimeUs; + } + if (end < 0 || info.presentationTimeUs < end) { + if (info.presentationTimeUs > lastTimestamp) { + info.offset = 0; + info.flags = extractor.getSampleFlags(); + if (mediaMuxer.writeSampleData(muxerTrackIndex, buffer, info, isAudio)) { + //didWriteData(messageObject, file, false, false); + } + } + lastTimestamp = info.presentationTimeUs; + } else { + eof = true; + } + } + if (!eof) { + extractor.advance(); + } + } else if (index == -1) { + eof = true; + } else { + extractor.advance(); + } + if (eof) { + inputDone = true; + } + } + + extractor.unselectTrack(trackIndex); + return startTime; + } + return -1; + } + + private boolean convertVideo(final VideoEditedInfo videoEditedInfo, String destPath) { + + long startTime = videoEditedInfo.startTime; + long endTime = videoEditedInfo.endTime; + int resultWidth = videoEditedInfo.resultWidth; + int resultHeight = videoEditedInfo.resultHeight; + int rotationValue = videoEditedInfo.rotationValue; + int originalWidth = videoEditedInfo.originalWidth; + int originalHeight = videoEditedInfo.originalHeight; + int originalVideoBitrate = videoEditedInfo.originalVideoBitrate; + int resultVideoBitrate = videoEditedInfo.resultVideoBitrate; + int rotateRender = 0; + File cacheFile = new File(destPath); + + if (rotationValue == 90) { + int temp = resultHeight; + resultHeight = resultWidth; + resultWidth = temp; + rotationValue = 0; + rotateRender = 270; + } else if (rotationValue == 180) { + rotateRender = 180; + rotationValue = 0; + } else if (rotationValue == 270) { + int temp = resultHeight; + resultHeight = resultWidth; + resultWidth = temp; + rotationValue = 0; + rotateRender = 90; + } + + File inputFile = new File(videoEditedInfo.originalPath); + if (!inputFile.canRead()) { + //didWriteData(messageObject, cacheFile, true, true); + Log.w(TAG, "Could not read video file to be recoded"); + return false; + } + + boolean error = false; + long videoStartTime = startTime; + + long time = System.currentTimeMillis(); + + if (resultWidth != 0 && resultHeight != 0) { + MP4Builder mediaMuxer = null; + MediaExtractor extractor = null; + + try { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + Mp4Movie movie = new Mp4Movie(); + movie.setCacheFile(cacheFile); + movie.setRotation(rotationValue); + movie.setSize(resultWidth, resultHeight); + mediaMuxer = new MP4Builder().createMovie(movie); + extractor = new MediaExtractor(); + extractor.setDataSource(inputFile.toString()); + + checkConversionCanceled(); + + if (resultVideoBitrate= 0) { + MediaCodec decoder = null; + MediaCodec encoder = null; + InputSurface inputSurface = null; + OutputSurface outputSurface = null; + + try { + long videoTime = -1; + boolean outputDone = false; + boolean inputDone = false; + boolean decoderDone = false; + int videoTrackIndex = -5; + + int colorFormat; + colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; + //Log.i("DeltaChat", "colorFormat = " + colorFormat); + + extractor.selectTrack(videoIndex); + if (startTime > 0) { + extractor.seekTo(startTime, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + } else { + extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + } + MediaFormat inputFormat = extractor.getTrackFormat(videoIndex); + + MediaFormat outputFormat = MediaFormat.createVideoFormat(MIME_TYPE, resultWidth, resultHeight); + outputFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat); + outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, resultVideoBitrate != 0 ? resultVideoBitrate : 921600); + outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 25); + outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10); + + encoder = MediaCodec.createEncoderByType(MIME_TYPE); + encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + inputSurface = new InputSurface(encoder.createInputSurface()); + inputSurface.makeCurrent(); + encoder.start(); + + decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)); + outputSurface = new OutputSurface(); + decoder.configure(inputFormat, outputSurface.getSurface(), null, 0); + decoder.start(); + + final int TIMEOUT_USEC = 2500; + ByteBuffer[] decoderInputBuffers = null; + ByteBuffer[] encoderOutputBuffers = null; + + checkConversionCanceled(); + + while (!outputDone) { + checkConversionCanceled(); + if (!inputDone) { + boolean eof = false; + int index = extractor.getSampleTrackIndex(); + if (index == videoIndex) { + int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC); + if (inputBufIndex >= 0) { + ByteBuffer inputBuf; + inputBuf = decoder.getInputBuffer(inputBufIndex); + int chunkSize = extractor.readSampleData(inputBuf, 0); + if (chunkSize < 0) { + decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + decoder.queueInputBuffer(inputBufIndex, 0, chunkSize, extractor.getSampleTime(), 0); + extractor.advance(); + } + } + } else if (index == -1) { + eof = true; + } + if (eof) { + int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC); + if (inputBufIndex >= 0) { + decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } + } + } + + boolean decoderOutputAvailable = !decoderDone; + boolean encoderOutputAvailable = true; + while (decoderOutputAvailable || encoderOutputAvailable) { + checkConversionCanceled(); + int encoderStatus = encoder.dequeueOutputBuffer(info, TIMEOUT_USEC); + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + encoderOutputAvailable = false; + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat newFormat = encoder.getOutputFormat(); + if (videoTrackIndex == -5) { + videoTrackIndex = mediaMuxer.addTrack(newFormat, false); + } + } else if (encoderStatus < 0) { + throw new RuntimeException("unexpected result from encoder.dequeueOutputBuffer: " + encoderStatus); + } else { + ByteBuffer encodedData; + encodedData = encoder.getOutputBuffer(encoderStatus); + if (encodedData == null) { + throw new RuntimeException("encoderOutputBuffer " + encoderStatus + " was null"); + } + if (info.size > 1) { + if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) { + if (mediaMuxer.writeSampleData(videoTrackIndex, encodedData, info, false)) { + //didWriteData(messageObject, cacheFile, false, false); + } + } else if (videoTrackIndex == -5) { + byte[] csd = new byte[info.size]; + encodedData.limit(info.offset + info.size); + encodedData.position(info.offset); + encodedData.get(csd); + ByteBuffer sps = null; + ByteBuffer pps = null; + for (int a = info.size - 1; a >= 0; a--) { + if (a > 3) { + if (csd[a] == 1 && csd[a - 1] == 0 && csd[a - 2] == 0 && csd[a - 3] == 0) { + sps = ByteBuffer.allocate(a - 3); + pps = ByteBuffer.allocate(info.size - (a - 3)); + sps.put(csd, 0, a - 3).position(0); + pps.put(csd, a - 3, info.size - (a - 3)).position(0); + break; + } + } else { + break; + } + } + + MediaFormat newFormat = MediaFormat.createVideoFormat(MIME_TYPE, resultWidth, resultHeight); + if (sps != null && pps != null) { + newFormat.setByteBuffer("csd-0", sps); + newFormat.setByteBuffer("csd-1", pps); + } + videoTrackIndex = mediaMuxer.addTrack(newFormat, false); + } + } + outputDone = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + encoder.releaseOutputBuffer(encoderStatus, false); + } + if (encoderStatus != MediaCodec.INFO_TRY_AGAIN_LATER) { + continue; + } + + if (!decoderDone) { + int decoderStatus = decoder.dequeueOutputBuffer(info, TIMEOUT_USEC); + if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + decoderOutputAvailable = false; + } else if (decoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + + } else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat newFormat = decoder.getOutputFormat(); + //Log.i("DeltaChat", "newFormat = " + newFormat); + } else if (decoderStatus < 0) { + throw new RuntimeException("unexpected result from decoder.dequeueOutputBuffer: " + decoderStatus); + } else { + boolean doRender; + doRender = info.size != 0; + if (endTime > 0 && info.presentationTimeUs >= endTime) { + inputDone = true; + decoderDone = true; + doRender = false; + info.flags |= MediaCodec.BUFFER_FLAG_END_OF_STREAM; + } + if (startTime > 0 && videoTime == -1) { + if (info.presentationTimeUs < startTime) { + doRender = false; + //Log.i("DeltaChat", "drop frame startTime = " + startTime + " present time = " + info.presentationTimeUs); + } else { + videoTime = info.presentationTimeUs; + } + } + decoder.releaseOutputBuffer(decoderStatus, doRender); + if (doRender) { + boolean errorWait = false; + try { + outputSurface.awaitNewImage(); + } catch (Exception e) { + errorWait = true; + Log.w(TAG, "error while waiting for recording output surface", e); + } + if (!errorWait) { + outputSurface.drawImage(false); + inputSurface.setPresentationTime(info.presentationTimeUs * 1000); + inputSurface.swapBuffers(); + } + } + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + decoderOutputAvailable = false; + //Log.i("DeltaChat", "decoder stream end"); + encoder.signalEndOfInputStream(); + } + } + } + } + } + if (videoTime != -1) { + videoStartTime = videoTime; + } + } catch (Exception e) { + Log.w(TAG,"Recoding video failed unexpectedly", e); + error = true; + } + + extractor.unselectTrack(videoIndex); + + if (outputSurface != null) { + outputSurface.release(); + } + if (inputSurface != null) { + inputSurface.release(); + } + if (decoder != null) { + decoder.stop(); + decoder.release(); + } + if (encoder != null) { + encoder.stop(); + encoder.release(); + } + + checkConversionCanceled(); + } + } else { + long videoTime = readAndWriteTrack(extractor, mediaMuxer, info, startTime, endTime, cacheFile, false); + if (videoTime != -1) { + videoStartTime = videoTime; + } + } + if (!error) { + readAndWriteTrack(extractor, mediaMuxer, info, videoStartTime, endTime, cacheFile, true); + } + } catch (Exception e) { + Log.w(TAG,"Recoding video failed unexpectedly/2", e); + error = true; + } finally { + if (extractor != null) { + extractor.release(); + } + if (mediaMuxer != null) { + try { + mediaMuxer.finishMovie(false); + } catch (Exception e) { + Log.w(TAG,"Flushing video failed unexpectedly", e); + } + } + //Log.i("DeltaChat", "time = " + (System.currentTimeMillis() - time)); + } + } else { + //didWriteData(messageObject, cacheFile, true, true); + Log.w(TAG,"Video width or height are 0, refusing recode."); + return false; + } + //didWriteData(messageObject, cacheFile, true, error); + return true; + } + + private static class VideoEditedInfo { + String originalPath; + float originalDurationMs; + long originalAudioBytes; + int originalRotationValue; + int originalWidth; + int originalHeight; + int originalVideoBitrate; + + long startTime; + long endTime; + int rotationValue; + + int resultWidth; + int resultHeight; + int resultVideoBitrate; + + int estimatedBytes; + } + + private static VideoEditedInfo getVideoEditInfoFromFile(String videoPath) { + // load information for the given video + VideoEditedInfo vei = new VideoEditedInfo(); + vei.originalPath = videoPath; + + try { + IsoFile isoFile = new IsoFile(videoPath); + List boxes = Path.getPaths(isoFile, "/moov/trak/"); + TrackHeaderBox trackHeaderBox = null; + + // if we find a video-track, we're just optimistic that the following recoding also works - + // if it fails, we know this then. + // older versions check before for paths as "/moov/trak/mdia/minf/stbl/stsd/mp4a/" + // (using Path.getPath()), however this is not sufficient, other paths as "moov/mvhd" + // are also valid and it is hard to maintain a list here. + + for (Box box : boxes) { + TrackBox trackBox = (TrackBox) box; + long sampleSizes = 0; + long trackBitrate = 0; + try { + MediaBox mediaBox = trackBox.getMediaBox(); + MediaHeaderBox mediaHeaderBox = mediaBox.getMediaHeaderBox(); + SampleSizeBox sampleSizeBox = mediaBox.getMediaInformationBox().getSampleTableBox().getSampleSizeBox(); + for (long size : sampleSizeBox.getSampleSizes()) { + sampleSizes += size; + } + float originalVideoSeconds = (float) mediaHeaderBox.getDuration() / (float) mediaHeaderBox.getTimescale(); + trackBitrate = (int) (sampleSizes * 8 / originalVideoSeconds); + vei.originalDurationMs = originalVideoSeconds * 1000; + } catch (Exception e) { + Log.w(TAG, "Get video info: Calculating sample sizes failed unexpectedly", e); + } + TrackHeaderBox headerBox = trackBox.getTrackHeaderBox(); + if (headerBox.getWidth() != 0 && headerBox.getHeight() != 0) { + trackHeaderBox = headerBox; + vei.originalVideoBitrate = (int) (trackBitrate / 100000 * 100000); + } else { + vei.originalAudioBytes += sampleSizes; + } + } + if (trackHeaderBox == null) { + Log.w(TAG, "Get video info: No trackHeaderBox"); + return null; + } + + Matrix matrix = trackHeaderBox.getMatrix(); + if (matrix.equals(Matrix.ROTATE_90)) { + vei.originalRotationValue = 90; + } else if (matrix.equals(Matrix.ROTATE_180)) { + vei.originalRotationValue = 180; + } else if (matrix.equals(Matrix.ROTATE_270)) { + vei.originalRotationValue = 270; + } + vei.originalWidth = (int) trackHeaderBox.getWidth(); + vei.originalHeight = (int) trackHeaderBox.getHeight(); + + } catch (Exception e) { + Log.w(TAG, "Get video info: Reading message info failed unexpectedly", e); + return null; + } + + return vei; + } + + private static int calculateEstimatedSize(float timeDelta, int resultBitrate, float originalDurationMs, long originalAudioBytes) { + long videoFramesSize = (long) (resultBitrate / 8 * (originalDurationMs /1000)); + int size = (int) ((originalAudioBytes + videoFramesSize) * timeDelta); + return size; + } + + private static void alert(Context context, String str) + { + Log.e(TAG, str); + Util.runOnMain(() -> new AlertDialog.Builder(context) + .setCancelable(false) + .setMessage(str) + .setPositiveButton(android.R.string.ok, null) + .show()); + } + + // prepareVideo() assumes the msg object is set up properly to being sent; + // the function fills out missing information and also recodes the video as needed. + // return: true=video might be prepared, can be sent, false=error + public static boolean prepareVideo(Context context, int chatId, DcMsg msg) { + final long MAX_BYTES = DcHelper.getInt(context, "sys.msgsize_max_recommended"); + final String TOO_BIG_FILE = "Video cannot be compressed to a reasonable size. Try a shorter video or a lower quality."; + try { + String inPath = msg.getFile(); + Log.i(TAG, "Preparing video: " + inPath); + + // try to get information from video file + VideoEditedInfo vei = getVideoEditInfoFromFile(inPath); + if (vei == null) { + Log.w(TAG, String.format("Recoding failed for %s: cannot get info", inPath)); + if (msg.getFilebytes() > MAX_BYTES+MAX_BYTES/4) { + alert(context, TOO_BIG_FILE); + return false; + } + return true; // if the file is small, send it without recoding + } + + vei.rotationValue = vei.originalRotationValue; + vei.startTime = 0; + vei.endTime = -1; + + // set these information to the message object (not yet in database); + // if we can recdode, this will be overwritten below + if (vei.originalRotationValue == 90 || vei.originalRotationValue == 270) { + msg.setDimension(vei.originalHeight, vei.originalWidth); + } else { + msg.setDimension(vei.originalWidth, vei.originalHeight); + } + msg.setDuration((int)vei.originalDurationMs); + + // check if video bitrate is already reasonable + final int MAX_KBPS = 1500000; + long inBytes = new File(inPath).length(); + if (inBytes > 0 && inBytes <= MAX_BYTES && vei.originalVideoBitrate <= MAX_KBPS*2 /*be tolerant as long the file size matches*/) { + Log.i(TAG, String.format("recoding for %s is not needed, %d bytes and %d kbps are ok", inPath, inBytes, vei.originalVideoBitrate)); + return true; + } + + // calculate new video bitrate, sth. between 200 kbps and 1500 kbps + long resultDurationMs = (long) vei.originalDurationMs; + long maxVideoBytes = MAX_BYTES - vei.originalAudioBytes - resultDurationMs /*10 kbps codec overhead*/; + vei.resultVideoBitrate = (int) (maxVideoBytes / Math.max(1, resultDurationMs / 1000) * 8); + + if (vei.resultVideoBitrate < 200000) { + vei.resultVideoBitrate = 200000; + } else if (vei.resultVideoBitrate > 500000) { + boolean hardCompression = Prefs.isHardCompressionEnabled(context); + if (resultDurationMs < 30 * 1000 && !hardCompression) { + vei.resultVideoBitrate = MAX_KBPS; // ~ 12 MB/minute, plus Audio + } else if (resultDurationMs < 60 * 1000 && !hardCompression) { + vei.resultVideoBitrate = 1000000; // ~ 8 MB/minute, plus Audio + } else { + vei.resultVideoBitrate = 500000; // ~ 3.7 MB/minute, plus Audio + } + } + + // calculate video dimensions + int maxSide = vei.resultVideoBitrate > 400000 ? 640 : 480; + vei.resultWidth = vei.originalWidth; + vei.resultHeight = vei.originalHeight; + if (vei.resultWidth > maxSide || vei.resultHeight > maxSide) { + float scale = vei.resultWidth > vei.resultHeight ? (float) maxSide / vei.resultWidth : (float) maxSide / vei.resultHeight; + vei.resultWidth *= scale; + vei.resultHeight *= scale; + } + + // we know the most important things now, prepare the message to get a responsive ui + if (vei.originalRotationValue == 90 || vei.originalRotationValue == 270) { + msg.setDimension(vei.resultHeight, vei.resultWidth); + } else { + msg.setDimension(vei.resultWidth, vei.resultHeight); + } + msg.setDuration((int) resultDurationMs); + + // calculate bytes + vei.estimatedBytes = VideoRecoder.calculateEstimatedSize((float) resultDurationMs / vei.originalDurationMs, + vei.resultVideoBitrate, vei.originalDurationMs, vei.originalAudioBytes); + + if (vei.estimatedBytes > MAX_BYTES+MAX_BYTES/4) { + alert(context, TOO_BIG_FILE); + return false; + } + + // recode + String tempPath = DcHelper.getBlobdirFile(DcHelper.getContext(context), inPath); + VideoRecoder videoRecoder = new VideoRecoder(); + if (!videoRecoder.convertVideo(vei, tempPath)) { + alert(context, String.format("Recoding failed for %s: cannot convert to temporary file %s", inPath, tempPath)); + return false; + } + + msg.setFileAndDeduplicate(tempPath, msg.getFilename(), msg.getFilemime()); + + Log.i(TAG, String.format("recoding for %s done", inPath)); + } + catch(Exception e) { + e.printStackTrace(); + } + + return true; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/webxdc/WebxdcGarbageCollectionWorker.java b/src/main/java/org/thoughtcrime/securesms/webxdc/WebxdcGarbageCollectionWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..b5ce3ae4bcd5727aa720f7a953b5ace575cbabf7 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/webxdc/WebxdcGarbageCollectionWorker.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.webxdc; + +import android.content.Context; +import android.util.Log; +import android.webkit.WebStorage; +import androidx.annotation.NonNull; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.work.ListenableWorker; +import androidx.work.WorkerParameters; +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.thoughtcrime.securesms.connect.DcHelper; + +public class WebxdcGarbageCollectionWorker extends ListenableWorker { + private static final String TAG = WebxdcGarbageCollectionWorker.class.getSimpleName(); + private Context context; + + public WebxdcGarbageCollectionWorker(Context context, WorkerParameters params) { + super(context, params); + this.context = context; + } + + @Override + public @NonNull ListenableFuture startWork() { + Log.i(TAG, "Running Webxdc storage garbage collection..."); + + final Pattern WEBXDC_URL_PATTERN = + Pattern.compile("^https?://acc(\\d+)-msg(\\d+)\\.localhost/?"); + + return CallbackToFutureAdapter.getFuture(completer -> { + WebStorage webStorage = WebStorage.getInstance(); + + webStorage.getOrigins((origins) -> { + if (origins == null || origins.isEmpty()) { + Log.i(TAG, "Done, no WebView origins found."); + completer.set(Result.success()); + return; + } + + Rpc rpc = DcHelper.getRpc(context); + if (rpc == null) { + Log.e(TAG, "Failed to get access to RPC, Webxdc storage garbage collection aborted."); + completer.set(Result.failure()); + return; + } + + for (Object key : origins.keySet()) { + String url = (String)key; + Matcher m = WEBXDC_URL_PATTERN.matcher(url); + if (m.matches()) { + int accId = Integer.parseInt(m.group(1)); + int msgId = Integer.parseInt(m.group(2)); + try { + if (rpc.getExistingMsgIds(accId, Collections.singletonList(msgId)).isEmpty()) { + webStorage.deleteOrigin(url); + Log.i(TAG, String.format("Deleted webxdc origin: %s", url)); + } else { + Log.i(TAG, String.format("Existing webxdc origin: %s", url)); + } + } catch (RpcException e) { + Log.e(TAG, "error calling rpc.getExistingMsgIds()", e); + completer.set(Result.failure()); + return; + } + } else { // old webxdc URL schemes, etc + webStorage.deleteOrigin(url); + Log.i(TAG, String.format("Deleted unknown origin: %s", url)); + } + } + + Log.i(TAG, "Done running Webxdc storage garbage collection."); + completer.set(Result.success()); + }); + + return "Webxdc Garbage Collector"; + }); + } +} diff --git a/src/main/res/anim/animation_toggle_in.xml b/src/main/res/anim/animation_toggle_in.xml new file mode 100644 index 0000000000000000000000000000000000000000..ffffd02d1e1472bca61c119118ef17dc5e6f2f43 --- /dev/null +++ b/src/main/res/anim/animation_toggle_in.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/animation_toggle_out.xml b/src/main/res/anim/animation_toggle_out.xml new file mode 100644 index 0000000000000000000000000000000000000000..1e6f7ed638f9951085017b31adf3482131696f93 --- /dev/null +++ b/src/main/res/anim/animation_toggle_out.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/fade_scale_in.xml b/src/main/res/anim/fade_scale_in.xml new file mode 100644 index 0000000000000000000000000000000000000000..0f2def07d368b1081bd2e796e9d864e38e4986c9 --- /dev/null +++ b/src/main/res/anim/fade_scale_in.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/anim/fade_scale_out.xml b/src/main/res/anim/fade_scale_out.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ee729071b69016389ecd93358c0261ec8d48fd0 --- /dev/null +++ b/src/main/res/anim/fade_scale_out.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/anim/slide_from_right.xml b/src/main/res/anim/slide_from_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..7dbec61f34856a387948d1ee54e89246410a6b1a --- /dev/null +++ b/src/main/res/anim/slide_from_right.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/slide_to_right.xml b/src/main/res/anim/slide_to_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..c655fcd12c92e51ec66d8e23a87787f0d92a105c --- /dev/null +++ b/src/main/res/anim/slide_to_right.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/animator/bottom_pause_to_play_animation.xml b/src/main/res/animator/bottom_pause_to_play_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..f5b474bb70936c40f90d82f25a312b642bedf198 --- /dev/null +++ b/src/main/res/animator/bottom_pause_to_play_animation.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/src/main/res/animator/bottom_play_to_pause_animation.xml b/src/main/res/animator/bottom_play_to_pause_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..4f2778d68539737cbcf7525b6d5837e2b0cf3b5a --- /dev/null +++ b/src/main/res/animator/bottom_play_to_pause_animation.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/src/main/res/animator/rotate_90_animation.xml b/src/main/res/animator/rotate_90_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..7d44ce6900a84f3c19ba68c5884c69ac188b6aab --- /dev/null +++ b/src/main/res/animator/rotate_90_animation.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/main/res/animator/rotate_minus_90_animation.xml b/src/main/res/animator/rotate_minus_90_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef9e1b6f1f7d2a3923523f52877d18f7dccbee46 --- /dev/null +++ b/src/main/res/animator/rotate_minus_90_animation.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/main/res/animator/upper_pause_to_play_animation.xml b/src/main/res/animator/upper_pause_to_play_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..880c7b0b83f37302bf259fe013e0eaa7d31a9b55 --- /dev/null +++ b/src/main/res/animator/upper_pause_to_play_animation.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/src/main/res/animator/upper_play_to_pause_animation.xml b/src/main/res/animator/upper_play_to_pause_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..ffa933231cbc578644e31466ea7707ee94a21f10 --- /dev/null +++ b/src/main/res/animator/upper_play_to_pause_animation.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/src/main/res/color/text_color_dark_theme.xml b/src/main/res/color/text_color_dark_theme.xml new file mode 100644 index 0000000000000000000000000000000000000000..95308fe1a5d6fda31ff1a62f584ea7b7d077fef1 --- /dev/null +++ b/src/main/res/color/text_color_dark_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/color/text_color_secondary_dark_theme.xml b/src/main/res/color/text_color_secondary_dark_theme.xml new file mode 100644 index 0000000000000000000000000000000000000000..5510b324c73dfd0f8ceb8d59371d763f6bd12426 --- /dev/null +++ b/src/main/res/color/text_color_secondary_dark_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/drawable-anydpi-v24/icon_notification.xml b/src/main/res/drawable-anydpi-v24/icon_notification.xml new file mode 100644 index 0000000000000000000000000000000000000000..d59648bca58b922b0b42e123fc2a7a44597aa628 --- /dev/null +++ b/src/main/res/drawable-anydpi-v24/icon_notification.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/src/main/res/drawable-anydpi-v24/notification_permanent.xml b/src/main/res/drawable-anydpi-v24/notification_permanent.xml new file mode 100644 index 0000000000000000000000000000000000000000..939f879467c57154e463e20458dfb0e8e857b093 --- /dev/null +++ b/src/main/res/drawable-anydpi-v24/notification_permanent.xml @@ -0,0 +1,18 @@ + + > + + + + + + + diff --git a/src/main/res/drawable-hdpi/check.png b/src/main/res/drawable-hdpi/check.png new file mode 100644 index 0000000000000000000000000000000000000000..9eebc48fee93d83d867f28e3cc6f85da8d5a502d Binary files /dev/null and b/src/main/res/drawable-hdpi/check.png differ diff --git a/src/main/res/drawable-hdpi/ic_account_box_dark.png b/src/main/res/drawable-hdpi/ic_account_box_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5aed1dfcd69a6670f4409f0a37227b367bac3229 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_account_box_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_account_box_light.png b/src/main/res/drawable-hdpi/ic_account_box_light.png new file mode 100644 index 0000000000000000000000000000000000000000..042b0b8559805e0777f4389d5bbb388955ea4781 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_account_box_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_add_white_24dp.png b/src/main/res/drawable-hdpi/ic_add_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..96614477b54aea4aa6666e0acfc4f0a3de0941da Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_add_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_advanced_white_24dp.png b/src/main/res/drawable-hdpi/ic_advanced_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3ce85689dd8a736c8ff22fde07c12f893a45a981 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_advanced_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_archive_white_24dp.png b/src/main/res/drawable-hdpi/ic_archive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bb72e890f6e7d5cd20a2cafed46c1fc12e9e3763 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-hdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cd1972677699802e4ef9723ea50fcb284f9a2d9e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_attach_grey600_24dp.png b/src/main/res/drawable-hdpi/ic_attach_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..821319bf7eb26b7b58dba88e912d346c1899c60d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_attach_grey600_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_attach_white_24dp.png b/src/main/res/drawable-hdpi/ic_attach_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..05ee61f8f52a24d769bf4db77314117cb7db564a Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_attach_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_blur_on_white_24.png b/src/main/res/drawable-hdpi/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..cab73449bf4ad907ee8a3aa07b00cd91863bc86d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable-hdpi/ic_brightness_6_white_24dp.png b/src/main/res/drawable-hdpi/ic_brightness_6_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7e9a08756d04566a9efc689c74f967b017d3c52e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_brightness_6_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp b/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..fad9c778a9cc89514470eca177bf2a8a8dfe37d5 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_brush_marker_32.webp b/src/main/res/drawable-hdpi/ic_brush_marker_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..fd4b6e8b23f36422a700aa4f3b0298972192cd1f Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_brush_marker_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png b/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..497c88ca82b139d8523f62d272569b97777cdec7 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_check_white_24dp.png b/src/main/res/drawable-hdpi/ic_check_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..468ea5acd0150a333e408b200fbb23793a53751e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_check_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_circle_fill_white_48dp.png b/src/main/res/drawable-hdpi/ic_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1fb5dab3b24e83c41bca5cb7baec7f495c9db9d2 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_clear_white_24dp.png b/src/main/res/drawable-hdpi/ic_clear_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a33d87a911c0fbb21dd0506c0647f168b6c71b35 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_clear_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_close_white_18dp.webp b/src/main/res/drawable-hdpi/ic_close_white_18dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..63a6bf18befe1e4df0319db364ce653a60f0b2e5 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_close_white_18dp.webp differ diff --git a/src/main/res/drawable-hdpi/ic_close_white_24dp.png b/src/main/res/drawable-hdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d42adbaae1e12935eadb9509537764676ea10b54 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_close_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_contact_picture.png b/src/main/res/drawable-hdpi/ic_contact_picture.png new file mode 100644 index 0000000000000000000000000000000000000000..3cfbff303a46170c9f8d73b1f7c6f29c299bd5e5 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_contact_picture.png differ diff --git a/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png b/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..aecd68fb57b9a4aca3795d448c2e95cfdafac7d4 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_create_white_24dp.png b/src/main/res/drawable-hdpi/ic_create_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ea806946d64f8302d157a23d15524e66d1883d6d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_create_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_crop_32.webp b/src/main/res/drawable-hdpi/ic_crop_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..9bb129a9a12ab044ae9c278de7d9dadb334d799f Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_crop_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_delete_white_24dp.png b/src/main/res/drawable-hdpi/ic_delete_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b1b2ed1b364a6ed1d4799b1f4be59687f0503458 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_delete_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_delivery_status_read.png b/src/main/res/drawable-hdpi/ic_delivery_status_read.png new file mode 100644 index 0000000000000000000000000000000000000000..801725b0819eba6accb7e1d595b16be5f262fe35 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_delivery_status_read.png differ diff --git a/src/main/res/drawable-hdpi/ic_delivery_status_sending.png b/src/main/res/drawable-hdpi/ic_delivery_status_sending.png new file mode 100644 index 0000000000000000000000000000000000000000..fbdcef3583e349b398dd3d0de9bd8c6f0f48ab6a Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_delivery_status_sending.png differ diff --git a/src/main/res/drawable-hdpi/ic_delivery_status_sent.png b/src/main/res/drawable-hdpi/ic_delivery_status_sent.png new file mode 100644 index 0000000000000000000000000000000000000000..3edcd7642463b57e08185a9fdba95b38ca5ca447 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_delivery_status_sent.png differ diff --git a/src/main/res/drawable-hdpi/ic_emoji_32.webp b/src/main/res/drawable-hdpi/ic_emoji_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..81453bfd59818656ac92b4cafe7a8b6b6f612441 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_emoji_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_flip_32.webp b/src/main/res/drawable-hdpi/ic_flip_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..749ec60629d782341ebe64846befb657533749e2 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_flip_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_forum_white_24dp.png b/src/main/res/drawable-hdpi/ic_forum_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2a8fb7e0c9a033fa562918ba9bab8042a95613ff Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_forum_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_group_white_24dp.png b/src/main/res/drawable-hdpi/ic_group_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..55e4c85c3dc0d35f41ad25ee055820a8cfceb6db Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_group_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_help_white_24dp.png b/src/main/res/drawable-hdpi/ic_help_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d02f020c829c21167bd73f15b6cc5db9f4e25137 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_help_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_image_dark.png b/src/main/res/drawable-hdpi/ic_image_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..49cecb88f60b3e862193b21855dbab860eb3bea2 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_image_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_image_light.png b/src/main/res/drawable-hdpi/ic_image_light.png new file mode 100644 index 0000000000000000000000000000000000000000..afce95a27b1111ce41ba888a714b375c4145c007 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_image_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_image_white_24dp.png b/src/main/res/drawable-hdpi/ic_image_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b414cf5b6881d6ec172d2a7fbd73ada5bbf167ab Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_image_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_info_outline_dark.png b/src/main/res/drawable-hdpi/ic_info_outline_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..50765d9f65c606581c1a1bfff227090944eb8823 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_info_outline_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_info_outline_light.png b/src/main/res/drawable-hdpi/ic_info_outline_light.png new file mode 100644 index 0000000000000000000000000000000000000000..765f2008a6d9e747b5affa3c837e1fd7dce534b1 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_info_outline_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png b/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..c09db6fbabc9be8ed7f46b40bc771b770af16381 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png b/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..84755e48817d86d61d09e70489b6c94b5c1f4d38 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.png b/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bbb4fb4dc0ff4f975a8c40f0157aa6fcab2ed6d4 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_keyboard_grey600_24dp.png b/src/main/res/drawable-hdpi/ic_keyboard_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb0adb5494d4a7c9e3e48f92790c63b47463eca Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_keyboard_grey600_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_keyboard_white_24dp.png b/src/main/res/drawable-hdpi/ic_keyboard_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9a66461887f7314b7b118807a441026526725fb3 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_keyboard_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_launch_white_24dp.png b/src/main/res/drawable-hdpi/ic_launch_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9bdb87244f9133deb5fe7333e77aadee9383b16d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_launch_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png b/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..04dec6088ba779c630e195af2e7b44fb1ee6e241 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_location_off_white_24.png b/src/main/res/drawable-hdpi/ic_location_off_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..86b7b3108f938884611c48e701b21ecdb5976348 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_location_off_white_24.png differ diff --git a/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png b/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7c281c3f52a13ac89d34337cddbe47c904b8b19c Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_lock_white_18dp.png b/src/main/res/drawable-hdpi/ic_lock_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..513d5cabb73542a2d5c532b960f70b3fb7401f3a Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_lock_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_lock_white_24dp.png b/src/main/res/drawable-hdpi/ic_lock_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..09812a5a16614fea606407b7c35a757edd3c4cde Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_lock_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mic_grey600_24dp.png b/src/main/res/drawable-hdpi/ic_mic_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9eddc686d4cb5d136b55da872e24ec2ffd2fa4da Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mic_grey600_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mic_white_24dp.png b/src/main/res/drawable-hdpi/ic_mic_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..56df87e9fa76e6f1817843167b6950c0c650afd6 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mic_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mic_white_48dp.png b/src/main/res/drawable-hdpi/ic_mic_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b0389382e4ef99837d898958cc880a77151a8bdb Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mic_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mood_grey600_24dp.png b/src/main/res/drawable-hdpi/ic_mood_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6eff495ad4a7b4db6592815d32ba07f5fb5c3e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mood_grey600_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mood_white_24dp.png b/src/main/res/drawable-hdpi/ic_mood_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b2e30742d2971f3751254096ca5f144592bf8d62 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mood_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_movie_creation_dark.png b/src/main/res/drawable-hdpi/ic_movie_creation_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..71d25cbb100dbe3cb991e89ea0dbc5821f799f5b Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_movie_creation_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_movie_creation_light.png b/src/main/res/drawable-hdpi/ic_movie_creation_light.png new file mode 100644 index 0000000000000000000000000000000000000000..dccabc292f6985a8be1d7100f29797683c2683fa Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_movie_creation_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_notifications_white_24dp.png b/src/main/res/drawable-hdpi/ic_notifications_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e5617555ee0f10ed912fab456b434a23e50e38c5 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_notifications_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png b/src/main/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..340c65b4f4c86d16458f18c4acdcd9cd253d3575 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_person_white_24dp.png b/src/main/res/drawable-hdpi/ic_person_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..56708b0bad6c193edb0bb0c7f39897566aff4b20 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_person_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_pets_white_24dp.png b/src/main/res/drawable-hdpi/ic_pets_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9094bb55a581a084a6c186df364d4a1a3b03e573 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_pets_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_photo_camera_dark.png b/src/main/res/drawable-hdpi/ic_photo_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cbaacfb0f6cf1fcb00133580a0d32c952e43bb61 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_photo_camera_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_photo_camera_light.png b/src/main/res/drawable-hdpi/ic_photo_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2a98d9a12d90a580ab0bc8c5b453749329853a62 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_photo_camera_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_pin_white.png b/src/main/res/drawable-hdpi/ic_pin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..6f308952c89a64c663c8210005a793b063422b99 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_pin_white.png differ diff --git a/src/main/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png b/src/main/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7c0c0bc2db59ff92059b817c1d32c24000f1d556 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_reply.png b/src/main/res/drawable-hdpi/ic_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..b3bae928957e22b4127367b9b0be667752df35de Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_reply.png differ diff --git a/src/main/res/drawable-hdpi/ic_reply_white_24dp.png b/src/main/res/drawable-hdpi/ic_reply_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0424c2bd6d6eaa5fcd3398f71b6e230ffc1caa7b Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_reply_white_36dp.png b/src/main/res/drawable-hdpi/ic_reply_white_36dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3f8076f25b892916c6212e536f3f1447a6860de1 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_reply_white_36dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_rotate_32.webp b/src/main/res/drawable-hdpi/ic_rotate_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..46f9698eba886453f81f40059f7234688637daa2 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_rotate_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_send_push.png b/src/main/res/drawable-hdpi/ic_send_push.png new file mode 100644 index 0000000000000000000000000000000000000000..5dff6e583983f715da5b5caa55ddbf1652ccc259 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_send_push.png differ diff --git a/src/main/res/drawable-hdpi/ic_send_push_white_24dp.png b/src/main/res/drawable-hdpi/ic_send_push_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..64f6ceb372109faa636308ba3d6c5506f6cff648 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_send_push_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_send_sms_insecure.png b/src/main/res/drawable-hdpi/ic_send_sms_insecure.png new file mode 100644 index 0000000000000000000000000000000000000000..a98189d5a8bd06e2936096dfb95e164d580d15b1 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_send_sms_insecure.png differ diff --git a/src/main/res/drawable-hdpi/ic_send_sms_insecure_dark.png b/src/main/res/drawable-hdpi/ic_send_sms_insecure_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd76e61960b5f89f5e3ff9068ef978314a49b7d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_send_sms_insecure_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_send_sms_white_24dp.png b/src/main/res/drawable-hdpi/ic_send_sms_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3e2e69d2e346589e8a998b0e64cb29a0910240 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_send_sms_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_swap_vert_white_24dp.png b/src/main/res/drawable-hdpi/ic_swap_vert_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..50a2b2d6c47f2d5a49cdaf93d1ce8b5e451f81d8 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_swap_vert_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png b/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2a83e2f61cab7ad9966fd9c37ee854595db5a5c7 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_text_32.webp b/src/main/res/drawable-hdpi/ic_text_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..3f0d7b0202ee76a58a52208da1d658e57d57c270 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_text_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_trash_filled_32.webp b/src/main/res/drawable-hdpi/ic_trash_filled_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..5adc7f92aa9bb66448178dbe69d3fb782f4a39dd Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_trash_filled_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.png b/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..18730f12f8b7a94fc49b26a916aa877091d3a9c2 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_undo_32.webp b/src/main/res/drawable-hdpi/ic_undo_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..5c0b44eb1bcf5ea04cbb637423a90f3f679b2ffd Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_undo_32.webp differ diff --git a/src/main/res/drawable-hdpi/ic_unlocked_white_24dp.png b/src/main/res/drawable-hdpi/ic_unlocked_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..41674f458184d12cd2a79d148838bb8a706794d5 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_unlocked_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_unpin_white.png b/src/main/res/drawable-hdpi/ic_unpin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..0de4bba0b4f1a8c4964f1ff1ee0c9a7a18671d5e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_unpin_white.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png b/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf04dc7205eebc7573b212571339df2d1addd94 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.png b/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a22789739b37277a39851a3b657d253696e9b1af Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_up_dark.png b/src/main/res/drawable-hdpi/ic_volume_up_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ab9c27c5afb3ba4b3bb49a307e2e000f6db50e84 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_up_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_up_light.png b/src/main/res/drawable-hdpi/ic_volume_up_light.png new file mode 100644 index 0000000000000000000000000000000000000000..b020322df874880570e957280edadc09ae3bcfaf Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_up_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_warning_dark.png b/src/main/res/drawable-hdpi/ic_warning_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3e808b04ce3c8939bc1a85809675bd46befea089 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_warning_dark.png differ diff --git a/src/main/res/drawable-hdpi/ic_warning_light.png b/src/main/res/drawable-hdpi/ic_warning_light.png new file mode 100644 index 0000000000000000000000000000000000000000..9ee697647693545cde51269898a8d5d57076fef3 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_warning_light.png differ diff --git a/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png b/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e0bdc4934dadf6a942afc479f374003db016c793 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_work_white_24dp.png b/src/main/res/drawable-hdpi/ic_work_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..87c5a053d1a3242bfab9ae404086a10231ceb638 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_work_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/icon_notification.png b/src/main/res/drawable-hdpi/icon_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..b449bccecec7a46f19a8f0e26da8c334e960b126 Binary files /dev/null and b/src/main/res/drawable-hdpi/icon_notification.png differ diff --git a/src/main/res/drawable-hdpi/notification_permanent.png b/src/main/res/drawable-hdpi/notification_permanent.png new file mode 100644 index 0000000000000000000000000000000000000000..ef1463004f72430f0d25419b6bf6822c23fc94db Binary files /dev/null and b/src/main/res/drawable-hdpi/notification_permanent.png differ diff --git a/src/main/res/drawable-hdpi/quick_camera_dark.png b/src/main/res/drawable-hdpi/quick_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3a3e571757b939c19df2a90507401cb2d44888 Binary files /dev/null and b/src/main/res/drawable-hdpi/quick_camera_dark.png differ diff --git a/src/main/res/drawable-hdpi/quick_camera_light.png b/src/main/res/drawable-hdpi/quick_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..c26a846e37ec072e31e0096415ec5857ba5e4ca0 Binary files /dev/null and b/src/main/res/drawable-hdpi/quick_camera_light.png differ diff --git a/src/main/res/drawable-land-night/background_hd.jpg b/src/main/res/drawable-land-night/background_hd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d476868da49e0526bb71e8230c3c07ff9feb16cc Binary files /dev/null and b/src/main/res/drawable-land-night/background_hd.jpg differ diff --git a/src/main/res/drawable-land/background_hd.jpg b/src/main/res/drawable-land/background_hd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d6078cb2d43e9403dc58a45a898594a0e98a1f94 Binary files /dev/null and b/src/main/res/drawable-land/background_hd.jpg differ diff --git a/src/main/res/drawable-ldrtl-hdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-ldrtl-hdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f5175576277d2a0f5939c65b3c2d0ac1c5e05c81 Binary files /dev/null and b/src/main/res/drawable-ldrtl-hdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-ldrtl-mdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-ldrtl-mdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..22a1140ae2a3d368b6e07ebc0b975e47245dad94 Binary files /dev/null and b/src/main/res/drawable-ldrtl-mdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-ldrtl-xhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-ldrtl-xhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d858f18e6c2ef050c2d06f205059dc15416f2cde Binary files /dev/null and b/src/main/res/drawable-ldrtl-xhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-ldrtl-xxhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-ldrtl-xxhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..614ad49a3e4fb4c29193b38001841b2486038bcc Binary files /dev/null and b/src/main/res/drawable-ldrtl-xxhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-ldrtl-xxxhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-ldrtl-xxxhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d409b544b7f62950a69d7d1ea58e97ef6c5ea546 Binary files /dev/null and b/src/main/res/drawable-ldrtl-xxxhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-ldrtl/message_bubble_background_received_alone.xml b/src/main/res/drawable-ldrtl/message_bubble_background_received_alone.xml new file mode 100644 index 0000000000000000000000000000000000000000..fff6b14b63a440a5f84e0022b50d6953c76d5614 --- /dev/null +++ b/src/main/res/drawable-ldrtl/message_bubble_background_received_alone.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone.xml b/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone.xml new file mode 100644 index 0000000000000000000000000000000000000000..196688a09c7e2bd588dfbd65afbe9eeefa6463ef --- /dev/null +++ b/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone_with_border.xml b/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone_with_border.xml new file mode 100644 index 0000000000000000000000000000000000000000..315f63174f098a75534fa0f3f24ad73082acbfd0 --- /dev/null +++ b/src/main/res/drawable-ldrtl/message_bubble_background_sent_alone_with_border.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-mdpi/check.png b/src/main/res/drawable-mdpi/check.png new file mode 100644 index 0000000000000000000000000000000000000000..62b60f287bdf90c158dc9ce176a1d71b56e5d26b Binary files /dev/null and b/src/main/res/drawable-mdpi/check.png differ diff --git a/src/main/res/drawable-mdpi/ic_account_box_dark.png b/src/main/res/drawable-mdpi/ic_account_box_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..285689f3c398db87122d892cd9fd7d3d82f28f99 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_account_box_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_account_box_light.png b/src/main/res/drawable-mdpi/ic_account_box_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2d9417bd099d2463d4c7b61be7d3cf9f4e9fe235 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_account_box_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_add_white_24dp.png b/src/main/res/drawable-mdpi/ic_add_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e5207aa8505315beb2e68762ee49a99836c1ee Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_add_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_advanced_white_24dp.png b/src/main/res/drawable-mdpi/ic_advanced_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..65af51ba04007f111ca635f9c4bfcedae9c87800 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_advanced_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_archive_white_24dp.png b/src/main/res/drawable-mdpi/ic_archive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f6aa3f966501c78876e3a9b6d7d4856382cabb4d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-mdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..4ef72eec99423c5d4f83227e34b24835a79f324f Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_attach_grey600_24dp.png b/src/main/res/drawable-mdpi/ic_attach_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a70bf5502b6ccaaf4793036fd66bb2cb1b8b7443 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_attach_grey600_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_attach_white_24dp.png b/src/main/res/drawable-mdpi/ic_attach_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a12d712617c1b675442d8e09938798ca5db07a01 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_attach_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_blur_on_white_24.png b/src/main/res/drawable-mdpi/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..f627de2e5a5acbc0c18bab14f53822170cb6d6a0 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable-mdpi/ic_brightness_6_white_24dp.png b/src/main/res/drawable-mdpi/ic_brightness_6_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e360fb747de258c510ca2caf485102a2c49380f9 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_brightness_6_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp b/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..d49bbfb962e622375ec61e0221b366b8846149e3 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_brush_marker_32.webp b/src/main/res/drawable-mdpi/ic_brush_marker_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..4354917b72284a11a20cd95f7e635a20396a4e26 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_brush_marker_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png b/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e830522008b0a1b1f39fdde1156ff1bae3f955e5 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_check_white_24dp.png b/src/main/res/drawable-mdpi/ic_check_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6a93d691945abe4c1a5bffa713df9fdf4d0c021c Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_check_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_circle_fill_white_48dp.png b/src/main/res/drawable-mdpi/ic_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..69f5c3910bf384a560d0ecebd69fbe94e0839a2d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_clear_white_24dp.png b/src/main/res/drawable-mdpi/ic_clear_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..00b0f17dfc3db2f74263c0b95387c2db27bb69a8 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_clear_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_close_white_18dp.webp b/src/main/res/drawable-mdpi/ic_close_white_18dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..779b7b513cefe1dd652eaa2db0f8b922d4350635 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_close_white_18dp.webp differ diff --git a/src/main/res/drawable-mdpi/ic_close_white_24dp.png b/src/main/res/drawable-mdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bb62b22d4af68a0b13b3b86e9a8cee73ef6f6d91 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_close_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_contact_picture.png b/src/main/res/drawable-mdpi/ic_contact_picture.png new file mode 100644 index 0000000000000000000000000000000000000000..3d605f1f5c178404532dc3ffb33984524b813cb3 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_contact_picture.png differ diff --git a/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png b/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..66a0a250b91891d192b376c6e210cc3e316bd209 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_create_white_24dp.png b/src/main/res/drawable-mdpi/ic_create_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f5ddc2f9211cfb25d79d59a4ac87bdb3f1d96cda Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_create_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_crop_32.webp b/src/main/res/drawable-mdpi/ic_crop_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..8a1b98be30a2933da45a051a7adef7dac7f52a0c Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_crop_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_delete_white_24dp.png b/src/main/res/drawable-mdpi/ic_delete_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a98c3d9424898913c2385ca83cb25fb33e92c5a8 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_delete_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_delivery_status_read.png b/src/main/res/drawable-mdpi/ic_delivery_status_read.png new file mode 100644 index 0000000000000000000000000000000000000000..e7c87594ca89578b9b0edde6db6968d4affd8c8f Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_delivery_status_read.png differ diff --git a/src/main/res/drawable-mdpi/ic_delivery_status_sending.png b/src/main/res/drawable-mdpi/ic_delivery_status_sending.png new file mode 100644 index 0000000000000000000000000000000000000000..b34ea32b8b676f2ca13445fe985479af991f1453 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_delivery_status_sending.png differ diff --git a/src/main/res/drawable-mdpi/ic_delivery_status_sent.png b/src/main/res/drawable-mdpi/ic_delivery_status_sent.png new file mode 100644 index 0000000000000000000000000000000000000000..08cb9ffd7b926471dd3f1861dbfb911b0aed40ce Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_delivery_status_sent.png differ diff --git a/src/main/res/drawable-mdpi/ic_emoji_32.webp b/src/main/res/drawable-mdpi/ic_emoji_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..958cbae15079d21f86c64f07cd6a030c01704feb Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_emoji_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_flip_32.png b/src/main/res/drawable-mdpi/ic_flip_32.png new file mode 100644 index 0000000000000000000000000000000000000000..b9fdbf3a47055b930cd1d6457d7f2288b7c51e7d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_flip_32.png differ diff --git a/src/main/res/drawable-mdpi/ic_forum_white_24dp.png b/src/main/res/drawable-mdpi/ic_forum_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..770224b9f633dbd1388cb69b59af46ad4c2d41f4 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_forum_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_group_white_24dp.png b/src/main/res/drawable-mdpi/ic_group_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0df7f41ab54afd94f3c4de555d36a227f996a4cd Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_group_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_help_white_24dp.png b/src/main/res/drawable-mdpi/ic_help_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e70a39f1274a591da0653eb8a42e42b53d288f8e Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_help_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_image_dark.png b/src/main/res/drawable-mdpi/ic_image_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3d0143714b6a4978cf7e05122d94e08e06485b3b Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_image_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_image_light.png b/src/main/res/drawable-mdpi/ic_image_light.png new file mode 100644 index 0000000000000000000000000000000000000000..3d57dd2b28bf358d9c576fd9d6f12017fff93bc4 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_image_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_image_white_24dp.png b/src/main/res/drawable-mdpi/ic_image_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d474bd577d00d2aa045685f38b1729e4b2c314e2 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_image_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_info_outline_dark.png b/src/main/res/drawable-mdpi/ic_info_outline_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6a5be39a003cfc68e9ee820cd14b6ad4ef228ae9 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_info_outline_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_info_outline_light.png b/src/main/res/drawable-mdpi/ic_info_outline_light.png new file mode 100644 index 0000000000000000000000000000000000000000..4d9f50cbc1ecf941e76ef27ddfb4ad00aeb54c1e Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_info_outline_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png b/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1ce4a3995ccdb7203eb58249e7324ca748f79a24 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png b/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b51ce3ed95a437af48d672cb4b8494807587c080 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.png b/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ef8a4b6a4c219baa03de2e6a31c6570b453c3444 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_keyboard_grey600_24dp.png b/src/main/res/drawable-mdpi/ic_keyboard_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2353c985766daf37b59eb10b64632fc8ba5ea69b Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_keyboard_grey600_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_keyboard_white_24dp.png b/src/main/res/drawable-mdpi/ic_keyboard_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5fd959bc29332fdafab6d27ce88dac399c80dc32 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_keyboard_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_launch_white_24dp.png b/src/main/res/drawable-mdpi/ic_launch_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..18fc7dda40855349c00fbead0e68db4e16a4e691 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_launch_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png b/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5b68bb59ae27a72a922626ac2d43f9578e853f73 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png b/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..933eb5148faeabee4ef4ad40ee14a4d6b0c08f5b Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_lock_white_18dp.png b/src/main/res/drawable-mdpi/ic_lock_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..70074a667b3e10c5b442d11adb51c03188bb0aae Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_lock_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_lock_white_24dp.png b/src/main/res/drawable-mdpi/ic_lock_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..60772036c3c06df6bdb35888aff197afd1d1cc3a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_lock_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mic_grey600_24dp.png b/src/main/res/drawable-mdpi/ic_mic_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7178b133e148e0cc75becd4ce37e0cce5187bd9e Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mic_grey600_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mic_white_24dp.png b/src/main/res/drawable-mdpi/ic_mic_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..199a33703e753c3248bb7e7713b0fdb0667518af Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mic_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mic_white_48dp.png b/src/main/res/drawable-mdpi/ic_mic_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9f44db5d21785d6d92316645fe5bf23ae994b77e Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mic_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mood_grey600_24dp.png b/src/main/res/drawable-mdpi/ic_mood_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ba9910e8e8949a04f9840a53694c792b6b2439 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mood_grey600_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mood_white_24dp.png b/src/main/res/drawable-mdpi/ic_mood_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2f72650c13c09db9505733b198847d1124c94324 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mood_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_movie_creation_dark.png b/src/main/res/drawable-mdpi/ic_movie_creation_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1638f00a7ca1ffb62f96ffd1a6ee9ba2f83efb3d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_movie_creation_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_movie_creation_light.png b/src/main/res/drawable-mdpi/ic_movie_creation_light.png new file mode 100644 index 0000000000000000000000000000000000000000..3506288f98fb818800f0e8f87bea3fba0e9fffff Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_movie_creation_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_notifications_white_24dp.png b/src/main/res/drawable-mdpi/ic_notifications_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..fee93eea6b1e59613839a323bf9f6c1287f24971 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_notifications_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png b/src/main/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..55b14334b2e297f755b72af38074550b6c0f232d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_person_white_24dp.png b/src/main/res/drawable-mdpi/ic_person_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b1c725da4a3cec7b655a06cb3a3c1eba39bdeb Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_person_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_pets_white_24dp.png b/src/main/res/drawable-mdpi/ic_pets_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1194342fb55b53f3e78ecd700da367987e717634 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_pets_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_photo_camera_dark.png b/src/main/res/drawable-mdpi/ic_photo_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9524a2f8e7e7e99649e7c7d87ea07970c162ef Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_photo_camera_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_photo_camera_light.png b/src/main/res/drawable-mdpi/ic_photo_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2c883fc7a0414d4e13c7be8145543a3545a54021 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_photo_camera_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_pin_white.png b/src/main/res/drawable-mdpi/ic_pin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..80beffef17f7eef0b865adaf09a9d58a6f281e16 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_pin_white.png differ diff --git a/src/main/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png b/src/main/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..64c1f79ab8bd5e22b34ec1649a87189825810e7d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_reply.png b/src/main/res/drawable-mdpi/ic_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..ce00dbc4b436cb7b84cfea033ad660f96bb306b9 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_reply.png differ diff --git a/src/main/res/drawable-mdpi/ic_reply_white_24dp.png b/src/main/res/drawable-mdpi/ic_reply_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..862114b82dafbbf8ca125d02ce7b723e901cf396 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_reply_white_36dp.png b/src/main/res/drawable-mdpi/ic_reply_white_36dp.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf2096dd8eaae60dc1febc820079c4112ac8fcb Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_reply_white_36dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_rotate_32.webp b/src/main/res/drawable-mdpi/ic_rotate_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..18f193269739dd2bc9cfff21ab724182d1badf4c Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_rotate_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_send_push.png b/src/main/res/drawable-mdpi/ic_send_push.png new file mode 100644 index 0000000000000000000000000000000000000000..f04c6b03c49b60cbf56f9909dc2da2e49761cfda Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_send_push.png differ diff --git a/src/main/res/drawable-mdpi/ic_send_push_white_24dp.png b/src/main/res/drawable-mdpi/ic_send_push_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7d1e725b40dbb6827710c51ff256152b6b46bce3 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_send_push_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_send_sms_insecure.png b/src/main/res/drawable-mdpi/ic_send_sms_insecure.png new file mode 100644 index 0000000000000000000000000000000000000000..210e8be48d9acd2faa90260f620e96ada6de7a7a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_send_sms_insecure.png differ diff --git a/src/main/res/drawable-mdpi/ic_send_sms_insecure_dark.png b/src/main/res/drawable-mdpi/ic_send_sms_insecure_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..19f0abb903d3c70c742225a6e3342679268eccad Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_send_sms_insecure_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_send_sms_white_24dp.png b/src/main/res/drawable-mdpi/ic_send_sms_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..834c579c0b69d64fbbee97e5f03d360436cd020a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_send_sms_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_swap_vert_white_24dp.png b/src/main/res/drawable-mdpi/ic_swap_vert_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0c7a9663c3f5c69b7cdbc11477333474d2d13cb9 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_swap_vert_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png b/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..73656906f9f84ceba70c77ae539e24571c8f8e47 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_text_32.webp b/src/main/res/drawable-mdpi/ic_text_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..8b450bea1ffc7d50f3fa3ca63309979d695c5be2 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_text_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_trash_filled_32.webp b/src/main/res/drawable-mdpi/ic_trash_filled_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..4264fd40a8b147771ae3ed46e2bdec511e75a8c4 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_trash_filled_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.png b/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec62cd34b2c63234ff5593637d5105bdb253343 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_undo_32.webp b/src/main/res/drawable-mdpi/ic_undo_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..1a2440dde8cf95a7f41cdd28a6e27efb2b07e3ca Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_undo_32.webp differ diff --git a/src/main/res/drawable-mdpi/ic_unlocked_white_24dp.png b/src/main/res/drawable-mdpi/ic_unlocked_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7654ff03023b2981b1550570bbec4096111fe756 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_unlocked_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_unpin_white.png b/src/main/res/drawable-mdpi/ic_unpin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..2dec79a016c1eb0607af885037539d8cbdb9053a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_unpin_white.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png b/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..db6550370c8ec3c0bd4412cfb1457036cafb7e69 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.png b/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..463011650021a1002a2ccad4a3ffdbf3b51e6de2 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_up_dark.png b/src/main/res/drawable-mdpi/ic_volume_up_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5b8a65b56ddc411bd3b4401fa199a3cd3485dd19 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_up_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_up_light.png b/src/main/res/drawable-mdpi/ic_volume_up_light.png new file mode 100644 index 0000000000000000000000000000000000000000..6de890621b773fc40198cce573a480aabad5b749 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_up_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_warning_dark.png b/src/main/res/drawable-mdpi/ic_warning_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cc815942632d4018e98b71129558fc7783cdab09 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_warning_dark.png differ diff --git a/src/main/res/drawable-mdpi/ic_warning_light.png b/src/main/res/drawable-mdpi/ic_warning_light.png new file mode 100644 index 0000000000000000000000000000000000000000..f4164a974627d6f2025fbfb230d6859673b2c037 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_warning_light.png differ diff --git a/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png b/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..58458b22a5a173563bb72a3a44e841a36880e309 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_work_white_24dp.png b/src/main/res/drawable-mdpi/ic_work_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ba06d79a766a3bd14329bab3baa73ae130736da7 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_work_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/icon_notification.png b/src/main/res/drawable-mdpi/icon_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..39f4be607b043842304f4b6ac631a92ef9661368 Binary files /dev/null and b/src/main/res/drawable-mdpi/icon_notification.png differ diff --git a/src/main/res/drawable-mdpi/notification_permanent.png b/src/main/res/drawable-mdpi/notification_permanent.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ab2df5aae9f484bd56fe6cc2410c79cde07ab3 Binary files /dev/null and b/src/main/res/drawable-mdpi/notification_permanent.png differ diff --git a/src/main/res/drawable-mdpi/quick_camera_dark.png b/src/main/res/drawable-mdpi/quick_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4dbf6c77bce2898fe96ca0c423836a5c297478a1 Binary files /dev/null and b/src/main/res/drawable-mdpi/quick_camera_dark.png differ diff --git a/src/main/res/drawable-mdpi/quick_camera_light.png b/src/main/res/drawable-mdpi/quick_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2a906080fd6df6db6c1aca7c27dbdf8f6b3ecf8b Binary files /dev/null and b/src/main/res/drawable-mdpi/quick_camera_light.png differ diff --git a/src/main/res/drawable-night/background_hd.jpg b/src/main/res/drawable-night/background_hd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dee961c386b2ff8992a9811624b4f95074ab349b Binary files /dev/null and b/src/main/res/drawable-night/background_hd.jpg differ diff --git a/src/main/res/drawable-night/button_secondary_background.xml b/src/main/res/drawable-night/button_secondary_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..170bd2d7ba4ecd3c93e762f672210ead9979910a --- /dev/null +++ b/src/main/res/drawable-night/button_secondary_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/res/drawable-night/jumpto_btn_bg.xml b/src/main/res/drawable-night/jumpto_btn_bg.xml new file mode 100644 index 0000000000000000000000000000000000000000..be9119122274bce0302895e402e00d1ac6eabd05 --- /dev/null +++ b/src/main/res/drawable-night/jumpto_btn_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background.xml b/src/main/res/drawable-night/pinned_list_item_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..dc98397b0dad4d3c854807af7a48def4a27006f0 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_blue.xml b/src/main/res/drawable-night/pinned_list_item_background_blue.xml new file mode 100644 index 0000000000000000000000000000000000000000..084144a4b06dad3e220d0c911395e860ef708eb8 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_blue.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_gray.xml b/src/main/res/drawable-night/pinned_list_item_background_gray.xml new file mode 100644 index 0000000000000000000000000000000000000000..c0e41aaf0e70ebf2985b163136231d3508f6d04b --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_gray.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_green.xml b/src/main/res/drawable-night/pinned_list_item_background_green.xml new file mode 100644 index 0000000000000000000000000000000000000000..683182c4e4b68e69f4dc5036e263f735a3ca2b72 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_green.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_pink.xml b/src/main/res/drawable-night/pinned_list_item_background_pink.xml new file mode 100644 index 0000000000000000000000000000000000000000..6004e621171b84f849f9a282a2645b3daab65a74 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_pink.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_purple.xml b/src/main/res/drawable-night/pinned_list_item_background_purple.xml new file mode 100644 index 0000000000000000000000000000000000000000..e9b33c6fb75a4f0927761df02b97e341675bcf78 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_purple.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-night/pinned_list_item_background_red.xml b/src/main/res/drawable-night/pinned_list_item_background_red.xml new file mode 100644 index 0000000000000000000000000000000000000000..6a27959dbe5bcdbcd74d68e478ae2b9eb29c5224 --- /dev/null +++ b/src/main/res/drawable-night/pinned_list_item_background_red.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable-xhdpi/attach_camera.png b/src/main/res/drawable-xhdpi/attach_camera.png new file mode 100644 index 0000000000000000000000000000000000000000..103a7ea48ca325011e42ff7ac64194648f4ec80b Binary files /dev/null and b/src/main/res/drawable-xhdpi/attach_camera.png differ diff --git a/src/main/res/drawable-xhdpi/attach_record_video.png b/src/main/res/drawable-xhdpi/attach_record_video.png new file mode 100644 index 0000000000000000000000000000000000000000..2048e151a20b182c0cf3666ba377c333cc89bf77 Binary files /dev/null and b/src/main/res/drawable-xhdpi/attach_record_video.png differ diff --git a/src/main/res/drawable-xhdpi/check.png b/src/main/res/drawable-xhdpi/check.png new file mode 100644 index 0000000000000000000000000000000000000000..a2e651eeea0df1d5007b638a295da9f6be82f611 Binary files /dev/null and b/src/main/res/drawable-xhdpi/check.png differ diff --git a/src/main/res/drawable-xhdpi/ic_account_box_dark.png b/src/main/res/drawable-xhdpi/ic_account_box_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..2559c4dbf1f2f83d309e8ee9bea986f3a230b99b Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_account_box_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_account_box_light.png b/src/main/res/drawable-xhdpi/ic_account_box_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd06d7487e76da8033c1b2eedaaf296ceffe3cb Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_account_box_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_add_white_24dp.png b/src/main/res/drawable-xhdpi/ic_add_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..4a01f78e3872fe474c24ab1c5ad508591bf5354c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_add_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_advanced_white_24dp.png b/src/main/res/drawable-xhdpi/ic_advanced_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bb697e7388ee0f8963bb9c4c1f4060ed0c0a73e6 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_advanced_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3513bd9fef620b059640829c1889f0df4fc2bc77 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-xhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..832f5a36172308b2c53cefe5098f828b0b4eae53 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_attach_grey600_24dp.png b/src/main/res/drawable-xhdpi/ic_attach_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3ec56929ca631e00b2081f6b5b83984f15b1ac8c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_attach_grey600_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_attach_white_24dp.png b/src/main/res/drawable-xhdpi/ic_attach_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3113268634dc035f56ae013074371d79c33411f0 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_attach_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_blur_on_white_24.png b/src/main/res/drawable-xhdpi/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..06539f1a8c95f0c54078e8a4e07a86e5bb3250ee Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable-xhdpi/ic_brightness_6_white_24dp.png b/src/main/res/drawable-xhdpi/ic_brightness_6_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..91567f02f141fb63156d2c2ddf13061d81931a18 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_brightness_6_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp b/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..6612f233119e332ea71b97163ec7188ea5d29793 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp b/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..46e4a7674ae0e97803a9126cd1df4353a0fbd5fa Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png b/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..be9fb226a53ce5ee4008cfafa0754f42284d51b3 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_check_white_24dp.png b/src/main/res/drawable-xhdpi/ic_check_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9868d19a42c48a737917ed5198a74cb175d122ea Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_check_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_circle_fill_white_48dp.png b/src/main/res/drawable-xhdpi/ic_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bfe4e66c18a4f596f11526f9d69d51077f6895eb Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png b/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..c0d62194e05bbf3c0d13bc031941807c36e7efd0 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp b/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..541301f06df075fbe5826fd9f146dd3ba90eca25 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_close_white_24dp.png b/src/main/res/drawable-xhdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..856427baf5ee8bde12f10b69e30ed7f3e3b99cdd Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_close_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_contact_picture.png b/src/main/res/drawable-xhdpi/ic_contact_picture.png new file mode 100644 index 0000000000000000000000000000000000000000..3a15249084111726960c257eeaacd07a26a15636 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_contact_picture.png differ diff --git a/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png b/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b0b3718738c74f80b9c88d540ca32465626db34d Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_create_white_24dp.png b/src/main/res/drawable-xhdpi/ic_create_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..548f6638cda6d8de8e7daa4c63c3730fadcc6622 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_create_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_crop_32.webp b/src/main/res/drawable-xhdpi/ic_crop_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..119f5f57acdc8c093ce477e24ae1fa68043cc5d2 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_crop_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png b/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d337127653da47a490b0ce0aff32b867eae09a95 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_delivery_status_read.png b/src/main/res/drawable-xhdpi/ic_delivery_status_read.png new file mode 100644 index 0000000000000000000000000000000000000000..44c51bd20616bb1eb08105d755f4a763961bc44e Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_delivery_status_read.png differ diff --git a/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png b/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png new file mode 100644 index 0000000000000000000000000000000000000000..aca7fe7ef33f888bbf2f7e7fcbf22327de6d3f0e Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png differ diff --git a/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png b/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png new file mode 100644 index 0000000000000000000000000000000000000000..586a11bfbdf6479b61ff9529987c2fff3e9fec38 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png differ diff --git a/src/main/res/drawable-xhdpi/ic_emoji_32.webp b/src/main/res/drawable-xhdpi/ic_emoji_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7360c116604c4e5a00ad9278665adb45e41c9a89 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_emoji_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_flip_32.webp b/src/main/res/drawable-xhdpi/ic_flip_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7b9d741c9cf83a05468d59fb6e370da73394a016 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_flip_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_forum_white_24dp.png b/src/main/res/drawable-xhdpi/ic_forum_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..88268845b68e33e701726c09cedddfcc19dc0f64 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_forum_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_group_white_24dp.png b/src/main/res/drawable-xhdpi/ic_group_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2bbeda7199f88826d48a5da77f694c5eae64356f Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_group_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_help_white_24dp.png b/src/main/res/drawable-xhdpi/ic_help_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cc56d36f1b6eecbe5107c5071b042291ef321870 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_help_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_image_dark.png b/src/main/res/drawable-xhdpi/ic_image_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e11a15faf24050066206479506c4c603af359758 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_image_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_image_light.png b/src/main/res/drawable-xhdpi/ic_image_light.png new file mode 100644 index 0000000000000000000000000000000000000000..5521a654cff81e4c8da5e07f8c236a1f5369b935 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_image_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_image_white_24dp.png b/src/main/res/drawable-xhdpi/ic_image_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2642b9e09ec00be308649f62d9323f22ae2b6c6c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_image_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_info_outline_dark.png b/src/main/res/drawable-xhdpi/ic_info_outline_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..09a0c88068227a5b1ac465dc4bb10ebc14d17939 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_info_outline_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_info_outline_light.png b/src/main/res/drawable-xhdpi/ic_info_outline_light.png new file mode 100644 index 0000000000000000000000000000000000000000..a4759ec06e7831261d0aa46de3e72ca99d9cb867 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_info_outline_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png b/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5e607cede302c55e7ba1c01a07195eef6df1f310 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png b/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..798ebd4e25f68b658c82e773eea97bd2ad412f17 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.png b/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..058cebb7f91ced42a84caa154c4eab09982096c7 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_keyboard_grey600_24dp.png b/src/main/res/drawable-xhdpi/ic_keyboard_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a3bf0eb21dd47798e278cfaadc33f69063ccca80 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_keyboard_grey600_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_keyboard_white_24dp.png b/src/main/res/drawable-xhdpi/ic_keyboard_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f0050c5f7c359bbb9d26ca0b28d97b09d361221f Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_keyboard_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_launch_white_24dp.png b/src/main/res/drawable-xhdpi/ic_launch_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9dbbc3f08f3087426ba98b1b0b21e25ab76ea11f Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_launch_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png b/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e081e1afb81a2181cfcfbea6f01de3accfa08587 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_location_chatlist.png b/src/main/res/drawable-xhdpi/ic_location_chatlist.png new file mode 100644 index 0000000000000000000000000000000000000000..df6b8a99df451a1b7dd405bcef68a35c3f30e7d4 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_location_chatlist.png differ diff --git a/src/main/res/drawable-xhdpi/ic_location_msg.png b/src/main/res/drawable-xhdpi/ic_location_msg.png new file mode 100644 index 0000000000000000000000000000000000000000..3fa06346eb7109afd2c4a6bf02c7638e38cce2fe Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_location_msg.png differ diff --git a/src/main/res/drawable-xhdpi/ic_location_off_white_24.png b/src/main/res/drawable-xhdpi/ic_location_off_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..b2b4d6e0ed853a45f6baab214658f3c429a0a932 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_location_off_white_24.png differ diff --git a/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png b/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..814ca8ddc442ae97d8a78693c841e33141f96759 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_lock_white_18dp.png b/src/main/res/drawable-xhdpi/ic_lock_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..999ce69a165d7daf77984bc1cadf285dea7346df Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_lock_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_lock_white_24dp.png b/src/main/res/drawable-xhdpi/ic_lock_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..dfccae7a0ecab74414c6bb09418b0dbbe2b5e6c9 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_lock_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mic_grey600_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..47d32b80ee130c19973fb55330af836b7403113e Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mic_grey600_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mic_white_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..70264345853805d534e41b3ad05c1084ecb23cb1 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mic_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mic_white_48dp.png b/src/main/res/drawable-xhdpi/ic_mic_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1e60c55db59ab6460005a84c2e5fdf8c380dd7 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mic_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mood_grey600_24dp.png b/src/main/res/drawable-xhdpi/ic_mood_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a20b035c0b4458b449adc73a732802fd9e257e Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mood_grey600_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mood_white_24dp.png b/src/main/res/drawable-xhdpi/ic_mood_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..91df77d62d6b3364104ed0e2e7be5d648ba0c2e8 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mood_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png b/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fc862cc1cda510268331a96b8912e03c62f6845c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_movie_creation_light.png b/src/main/res/drawable-xhdpi/ic_movie_creation_light.png new file mode 100644 index 0000000000000000000000000000000000000000..5683744ed5cae4822658107159e00140719a2af2 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_movie_creation_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_notifications_white_24dp.png b/src/main/res/drawable-xhdpi/ic_notifications_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..aefc6cd6034f7b25bbb447853d82a814998682df Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_notifications_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png b/src/main/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bd2c7793dd217a0f955a0d58fed7d6a637072b05 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_person_white_24dp.png b/src/main/res/drawable-xhdpi/ic_person_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..aea15f0be51cfef4c218f7362a2ab739ac04245b Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_person_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png b/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f28287f35935dcd8c7ab1ae2b1c58ddcd5e9d53b Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png b/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..378b8de05cf9616deec4caee9547c81ce9ab83bb Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_photo_camera_light.png b/src/main/res/drawable-xhdpi/ic_photo_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..75a913f44948950c236f392ea0cb6a9246a02309 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_photo_camera_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_pin_white.png b/src/main/res/drawable-xhdpi/ic_pin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..c617f195455e523023a0ea71cffbaa3ee384df64 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_pin_white.png differ diff --git a/src/main/res/drawable-xhdpi/ic_pinned_chatlist.png b/src/main/res/drawable-xhdpi/ic_pinned_chatlist.png new file mode 100644 index 0000000000000000000000000000000000000000..2a73bf9b4a5d3a33d17916aa27960bbd8dde004b Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_pinned_chatlist.png differ diff --git a/src/main/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png b/src/main/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..12ed3bfcb86e94e2a9aaeb0e38cb1d7330df1e39 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_reply.png b/src/main/res/drawable-xhdpi/ic_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..31df111267643cb59e5a7649734e4225182af620 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_reply.png differ diff --git a/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..885623e4d0bb82cbb39424905a689b9bc6af22c1 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png b/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0f11be49564fa35f2bc1d42959ca9e8347023099 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_rotate_32.webp b/src/main/res/drawable-xhdpi/ic_rotate_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..e24c33da1e7fa76935c3ba7b510cdeb872542372 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_rotate_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_search_down.png b/src/main/res/drawable-xhdpi/ic_search_down.png new file mode 100644 index 0000000000000000000000000000000000000000..23e4d7c4f20ea277fbb224475149fbb499bb13fc Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_search_down.png differ diff --git a/src/main/res/drawable-xhdpi/ic_search_up.png b/src/main/res/drawable-xhdpi/ic_search_up.png new file mode 100644 index 0000000000000000000000000000000000000000..b2286ea5abc007df8cd543e87a075ba6917a4c03 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_search_up.png differ diff --git a/src/main/res/drawable-xhdpi/ic_send_push.png b/src/main/res/drawable-xhdpi/ic_send_push.png new file mode 100644 index 0000000000000000000000000000000000000000..4861371e444cfc4855c6a9b153b2f476e5346816 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_send_push.png differ diff --git a/src/main/res/drawable-xhdpi/ic_send_push_white_24dp.png b/src/main/res/drawable-xhdpi/ic_send_push_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2a0f54fb53300cab712b18e40cb2d30b980155ac Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_send_push_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_send_sms_insecure.png b/src/main/res/drawable-xhdpi/ic_send_sms_insecure.png new file mode 100644 index 0000000000000000000000000000000000000000..96648d5573f3993d9624b6828b8c7505d2ccd8c9 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_send_sms_insecure.png differ diff --git a/src/main/res/drawable-xhdpi/ic_send_sms_insecure_dark.png b/src/main/res/drawable-xhdpi/ic_send_sms_insecure_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..04b7ecb4e51ee720896089665de01ef8ed5fa266 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_send_sms_insecure_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_send_sms_white_24dp.png b/src/main/res/drawable-xhdpi/ic_send_sms_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..68dafe27a8c345586a6f4eb23831e722555d5f86 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_send_sms_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_swap_vert_white_24dp.png b/src/main/res/drawable-xhdpi/ic_swap_vert_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..eeffa1616b303283a7286a6d05349ef83e6317b8 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_swap_vert_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png b/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d8be63d0b9cba4935882d574cbe38ee9fc662a58 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_text_32.webp b/src/main/res/drawable-xhdpi/ic_text_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..cce43b52d709fff0f3f64a393e10168ff5e7756e Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_text_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp b/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..e3605ca3cd60592931018a90af0992526e07c0d1 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.png b/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a0a1509a16df382eb674cddbaf52e6e70292b30c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_undo_32.webp b/src/main/res/drawable-xhdpi/ic_undo_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7c407ef043321619aeb9367af3e93f1d75d218ca Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_undo_32.webp differ diff --git a/src/main/res/drawable-xhdpi/ic_unlocked_white_24dp.png b/src/main/res/drawable-xhdpi/ic_unlocked_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5823f194084557b9dd4dfd878680c3dae215fc09 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_unlocked_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_unpin_white.png b/src/main/res/drawable-xhdpi/ic_unpin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..62781c27edc0e8af71fc1e4b7cbfd923e047ff8c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_unpin_white.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png b/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3a074ee278228ca90c7a1a988878134456466468 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.png b/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6577af1a8fb62c7ee0444defd25dbc7348d2e314 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_up_dark.png b/src/main/res/drawable-xhdpi/ic_volume_up_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d613fa72b7907e8edd5dbaf2af4c28804ddcb064 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_up_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_up_light.png b/src/main/res/drawable-xhdpi/ic_volume_up_light.png new file mode 100644 index 0000000000000000000000000000000000000000..d62b97730c8c954b7ea0d9ef1841812ef93ef9d1 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_up_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_warning_dark.png b/src/main/res/drawable-xhdpi/ic_warning_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..2dccad27daf40019a9605a6c67226b9e93cedbf5 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_warning_dark.png differ diff --git a/src/main/res/drawable-xhdpi/ic_warning_light.png b/src/main/res/drawable-xhdpi/ic_warning_light.png new file mode 100644 index 0000000000000000000000000000000000000000..489662e29ac750da41ebc03a11ccaa71c8d3993f Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_warning_light.png differ diff --git a/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png b/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..123f780c711c92f1947ece4e58a29b675842ed82 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_work_white_24dp.png b/src/main/res/drawable-xhdpi/ic_work_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..10ddce1027eeabb3b8974c993e42f45eb9183f26 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_work_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/icon_notification.png b/src/main/res/drawable-xhdpi/icon_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..94e5009e5ef656fd69ae416257cd7fa4900f80a4 Binary files /dev/null and b/src/main/res/drawable-xhdpi/icon_notification.png differ diff --git a/src/main/res/drawable-xhdpi/notification_permanent.png b/src/main/res/drawable-xhdpi/notification_permanent.png new file mode 100644 index 0000000000000000000000000000000000000000..6a9a9434fd992c8474894c4dd67bfe8a049dd77b Binary files /dev/null and b/src/main/res/drawable-xhdpi/notification_permanent.png differ diff --git a/src/main/res/drawable-xhdpi/quick_camera_dark.png b/src/main/res/drawable-xhdpi/quick_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..753805275baa08d95ede5eddb9f5bbf40a23395b Binary files /dev/null and b/src/main/res/drawable-xhdpi/quick_camera_dark.png differ diff --git a/src/main/res/drawable-xhdpi/quick_camera_light.png b/src/main/res/drawable-xhdpi/quick_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..3be61d73f4e5742d73b926aa601c3547772c1303 Binary files /dev/null and b/src/main/res/drawable-xhdpi/quick_camera_light.png differ diff --git a/src/main/res/drawable-xhdpi/slidearrow.png b/src/main/res/drawable-xhdpi/slidearrow.png new file mode 100644 index 0000000000000000000000000000000000000000..45e9bd94222ba6acd414e6706737cdacce253bfc Binary files /dev/null and b/src/main/res/drawable-xhdpi/slidearrow.png differ diff --git a/src/main/res/drawable-xxhdpi/check.png b/src/main/res/drawable-xxhdpi/check.png new file mode 100644 index 0000000000000000000000000000000000000000..636216910b186b4096c58ac69dd2c949dd0990d9 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/check.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_account_box_dark.png b/src/main/res/drawable-xxhdpi/ic_account_box_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a95c8006c548d89c1fde98cf6fa78ee3422be346 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_account_box_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_account_box_light.png b/src/main/res/drawable-xxhdpi/ic_account_box_light.png new file mode 100644 index 0000000000000000000000000000000000000000..67716eff3b274702a606558ca83b864207d3b3db Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_account_box_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..923c8561c65e7f90c8967f69bad1984519974f79 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_advanced_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_advanced_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3ddb66e76b8a9054b3121ca6dffc0cf8ee6e815f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_advanced_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..00e04e42bfd86f7a5291d885f43538de3bf4e870 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..32a6d91ce8618ff42524d9e075451a13b2945f87 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_attach_grey600_24dp.png b/src/main/res/drawable-xxhdpi/ic_attach_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d3b05348caaf54405c7d386ee279384dbde91c7a Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_attach_grey600_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_attach_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_attach_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..158fd76fc7a595bae73bef577cce2ef39a59d06f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_attach_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_blur_on_white_24.png b/src/main/res/drawable-xxhdpi/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..a3650b06a371848933651dec6b98c9f28a6a6efe Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_brightness_6_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_brightness_6_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..194c22d48cf54e1788c46133b1da28b8534406c6 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_brightness_6_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp b/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7f1f6b8a30e2be4a73fa5a6a622a305688d69c36 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp b/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dcfc423dd2666acca50ea7db9f8512eaad25f34 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..c8e69dcebb98d43695027fcc7e39a339c84dda51 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_check_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_check_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5fbc99894453bbe3c50edb81cd1cadddbb39ff3a Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_check_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_circle_fill_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5686c5c4023a3c47e20dd06d24ace985c194a020 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..85424b8f63abbe05f0ab68d6a31f13456262575d Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp b/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b7a9e54d3f139aa782d2661ed559668a27068b7 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1690d6f73209c39d96794aaa70e88e9fd85eceea Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_contact_picture.png b/src/main/res/drawable-xxhdpi/ic_contact_picture.png new file mode 100644 index 0000000000000000000000000000000000000000..47187554f8c976e13ae05f4c35b3bd8bff1d6f5a Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_contact_picture.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6f8c7a1dc92733946835b06e8c0e47d3f9ea8baf Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8452719edd64ce27519597fdf263bc80b8269a2c Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_crop_32.webp b/src/main/res/drawable-xxhdpi/ic_crop_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..83607ce79fb583ec5f6ae0b55511eb9515a5f7e2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_crop_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f348301f3a9920049b65c47338ececb55fe8e626 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png b/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png new file mode 100644 index 0000000000000000000000000000000000000000..e11c4141cd15178adc625d5ed3a6aa5ff121c298 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png b/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png new file mode 100644 index 0000000000000000000000000000000000000000..e7850e2fb946364f12cdbc92e03acd354ac9f34c Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png b/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png new file mode 100644 index 0000000000000000000000000000000000000000..27f3e463a37951351d001cbf5b83f2e31625165b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_emoji_32.webp b/src/main/res/drawable-xxhdpi/ic_emoji_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..6877bb40fd45ba6c4d013a63af8bdc54f487276e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_emoji_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_flip_32.webp b/src/main/res/drawable-xxhdpi/ic_flip_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7133b2d6f7d52d3d93068d7be875affe782d9f4f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_flip_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_forum_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_forum_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..293b773d4681cfd6c2fcd95ddbf8e61285715d06 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_forum_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_group_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_group_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a6ed594f7ebac23f6601273237f0f923fdebdb18 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_group_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_help_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_help_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1cac3e8688ba1b1bd13a4f086a51c5a3aa7bc65d Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_help_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_image_dark.png b/src/main/res/drawable-xxhdpi/ic_image_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..95bac3e67cddeb930355b9b74ae81367965b0170 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_image_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_image_light.png b/src/main/res/drawable-xxhdpi/ic_image_light.png new file mode 100644 index 0000000000000000000000000000000000000000..1614d5a5d17ec806808c264090968d6a1b92ad4f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_image_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f9f1defa6df89b5a7a68df6787a4ba799d3bd3b2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png b/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..dcdacc4623659e54de2820acab9da0c618e40257 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_info_outline_light.png b/src/main/res/drawable-xxhdpi/ic_info_outline_light.png new file mode 100644 index 0000000000000000000000000000000000000000..03b5165eb0807cf524ff532cd7e2ab8d1efdff78 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_info_outline_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bdbde04c23cdd48834e2de483f709b97f8e20a8b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f3e153b45eb7886c314afd8642bc9018c1f2b5bd Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f9622b7bee7a9d6f1b8ffcc8acada4b620896452 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_keyboard_grey600_24dp.png b/src/main/res/drawable-xxhdpi/ic_keyboard_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..83f3e54e6c4d6d269b17f792b676b32cd3364aef Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_keyboard_grey600_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_keyboard_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_keyboard_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2ffbe3211b28918cfa787d957f9ef75ede2a183f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_keyboard_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb8dba76c4d26c99e70879c2bd7136137f2dac1 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8c43c1c243551b18046f6c2413fff1cb2146526d Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_location_off_white_24.png b/src/main/res/drawable-xxhdpi/ic_location_off_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..c96478543f9345326ef241b395bf8b326e866df3 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_location_off_white_24.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..078b10d4fb334b98e6a51010ca48513017046656 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_lock_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_lock_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3e89917b52351461ab44e567a9ba7a708db1fed9 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_lock_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0dcada814e6f4d28ec42e679c91ece10184ed097 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mic_grey600_24dp.png b/src/main/res/drawable-xxhdpi/ic_mic_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..374b0b627b642fdd73c6ee6da33889c49f113139 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mic_grey600_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mic_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_mic_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..dda69060341d4f1ef5bd7dca3870003d838854be Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mic_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mic_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_mic_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ad0460c0a800207e4064328d05994b4ca9656764 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mic_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mood_grey600_24dp.png b/src/main/res/drawable-xxhdpi/ic_mood_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cce3d5797c0fe76ea4094562c641f31340e44f99 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mood_grey600_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mood_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_mood_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..30b9025329333284f5e88d502ae664d4cbc1b2cd Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mood_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png b/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..bb5c3ed9b662d7eedcc85a70dae7a7fa2ffc4431 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_movie_creation_light.png b/src/main/res/drawable-xxhdpi/ic_movie_creation_light.png new file mode 100644 index 0000000000000000000000000000000000000000..65d596e157a8dd6f367a573ffd9c9a9b05a53f28 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_movie_creation_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_notifications_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_notifications_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9bf2038e1209b2439b4de41c62dbd4025a953065 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_notifications_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..54b538b4005f3875f31cf6a14a35b2f72b0b3bf7 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..184f7418d50ec4554539137f1abcaa3170b4643c Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..6996e7cad4485d64e8b1418b750659727fcc924e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png b/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4c9226587b913729a6730ecc0eb947338674f081 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_photo_camera_light.png b/src/main/res/drawable-xxhdpi/ic_photo_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..5b3afefbc95a13a9474b78c215a85436f694693e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_photo_camera_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_pin_white.png b/src/main/res/drawable-xxhdpi/ic_pin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..0481a3c8ec3f5874d93548e4d193656ad3a3594b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_pin_white.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..23e189c8fcd5250cae4c928b04dff15e851f5ddb Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_reply.png b/src/main/res/drawable-xxhdpi/ic_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..7a341b8305b22175aff82ad4d51b52182fb7a4f2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_reply.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..de0dad2047bd788a1a8fb8481e55a65bb3f80058 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png b/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png new file mode 100644 index 0000000000000000000000000000000000000000..24e00b240899c14bfeaaf118c35242b54037fa07 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_rotate_32.webp b/src/main/res/drawable-xxhdpi/ic_rotate_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..886e5541d60a3c2c9fe2362db695149a9ed362ff Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_rotate_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0bbeab15018d2d779acfa48a6d31812bd822c0c2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_send_push.png b/src/main/res/drawable-xxhdpi/ic_send_push.png new file mode 100644 index 0000000000000000000000000000000000000000..52d2f8286b100bb61c75baae9d8cfa29cf26febb Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_send_push.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_send_push_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_send_push_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3935e2851d312cbc0778ac49f3197325334a576d Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_send_push_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_send_sms_insecure.png b/src/main/res/drawable-xxhdpi/ic_send_sms_insecure.png new file mode 100644 index 0000000000000000000000000000000000000000..6c23ebea64165d953b711cae6e52d0c84c4a173b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_send_sms_insecure.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_send_sms_insecure_dark.png b/src/main/res/drawable-xxhdpi/ic_send_sms_insecure_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8cfe4d874ab17d71759f151ef1be900214200af7 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_send_sms_insecure_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_send_sms_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_send_sms_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ef5456760f28a826ed872eb1377067a5b24b0a2f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_send_sms_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_swap_vert_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_swap_vert_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2e03445b5f2130e204120639174f7291de051008 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_swap_vert_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..aeb295f2ec08452b8a4b3bdef3d7c1914bdebe6f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_text_32.webp b/src/main/res/drawable-xxhdpi/ic_text_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..e4ced150dc0e94f247dea3ceb6a63fdcb6925499 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_text_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp b/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..c1a094822917afa7c34fab7a60e83210b575a586 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..20d015751f5e7a7654fecd4d1beb6ccdb1033f8b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_undo_32.webp b/src/main/res/drawable-xxhdpi/ic_undo_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..7611c26ce1b250577fe481e823ef0e2a4bc94dc7 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_undo_32.webp differ diff --git a/src/main/res/drawable-xxhdpi/ic_unlocked_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_unlocked_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbf202ecb9c6070bb0ca4e21f639fb1e79a3f18 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_unlocked_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_unpin_white.png b/src/main/res/drawable-xxhdpi/ic_unpin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..95c07e578cccf120d49e99e36701e8866dbd3fca Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_unpin_white.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png b/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..df2042188c4767f0bb410503e1753410a2827d66 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f3bd44fc49495232bf8c76896cf4ae7aa8416b5f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png b/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4105b0fbe6053d100ea1750bffb70c8a63303e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_up_light.png b/src/main/res/drawable-xxhdpi/ic_volume_up_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ab2186867f9f23255821fc05d97118438da2d7f4 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_up_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_warning_dark.png b/src/main/res/drawable-xxhdpi/ic_warning_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..93326709f547d3a392795fbdaf26c42588ed6244 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_warning_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_warning_light.png b/src/main/res/drawable-xxhdpi/ic_warning_light.png new file mode 100644 index 0000000000000000000000000000000000000000..46255007f77de0e483cccef2a61e1bbe9c805ae9 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_warning_light.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b22b6eff8a15c97ba1a254a7073a02be0581cd Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..af82415d5fda4a33df0035fe12fc3054f1b99755 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/icon_notification.png b/src/main/res/drawable-xxhdpi/icon_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..7d9bf798e516381bff0ca25b7008155a2a86abdb Binary files /dev/null and b/src/main/res/drawable-xxhdpi/icon_notification.png differ diff --git a/src/main/res/drawable-xxhdpi/notification_permanent.png b/src/main/res/drawable-xxhdpi/notification_permanent.png new file mode 100644 index 0000000000000000000000000000000000000000..46b6441cf00944e2a0e1803f29cff6e32e922ee2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/notification_permanent.png differ diff --git a/src/main/res/drawable-xxhdpi/quick_camera_dark.png b/src/main/res/drawable-xxhdpi/quick_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c1a3549bfc0d4b26e9beaf97a65220052ffab653 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/quick_camera_dark.png differ diff --git a/src/main/res/drawable-xxhdpi/quick_camera_light.png b/src/main/res/drawable-xxhdpi/quick_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..62d83b253db2a6a8b087097a760a81fbf7d93394 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/quick_camera_light.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_advanced_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_advanced_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..4739d22711284c4ec0e1a3593a7960cc20647bb5 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_advanced_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..34cd3fd80592159be9a91ff48bcd8a3bc11bdf11 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_arrow_back_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_arrow_back_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e27034d67874687a900f0f960c662e94cd633e2a Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_arrow_back_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_blur_on_white_24.png b/src/main/res/drawable-xxxhdpi/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..5ccf530a75212061c26185bda9cde65b78a5a386 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_brightness_6_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_brightness_6_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..b286193805d577604f38bd04fca8707adf76b679 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_brightness_6_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp b/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..67f8ca11629803c5229ef0cefd54147332af82b5 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp b/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..ee6f21d6c6539d92da6ea229bfeb89a64891fd7c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..777658e95515ca47c9852d00621e2e6d45abc5c7 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6962792863812399eecff1d6a27a3d84e80aff Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_circle_fill_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..aad08528bc324148cb6d0c422bf7b5afeb670550 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..583f52c74c0f657f5cb6be828b1fafa60c9f112a Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp b/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..1ae09899eec96346bb4feaf738b9babff6e5c428 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8d6b78513bd05b94e57435e3f24908888fd362fa Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ff65f6d247d1b32baa93b1cfa8d8c016d2b232f4 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_crop_32.webp b/src/main/res/drawable-xxxhdpi/ic_crop_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..39832a055c85dd57231494b87451a5782ad3d84f Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_crop_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d59fe5de317197fbda77c586e7f21d0aea2dae58 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png b/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png new file mode 100644 index 0000000000000000000000000000000000000000..1ecf8f4edaeb83201e2dd6f7fa5e511bf68362be Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png b/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png new file mode 100644 index 0000000000000000000000000000000000000000..7eef9229eaadcc45d758822c3fed9d9acaf612e3 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png b/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png new file mode 100644 index 0000000000000000000000000000000000000000..0d5c40315613afca45a0bb2149b94d6fb53a8b54 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_emoji_32.webp b/src/main/res/drawable-xxxhdpi/ic_emoji_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..b7ad0d9db9ade595c52ffc4d2311ad21ef40cec8 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_emoji_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_flip_32.webp b/src/main/res/drawable-xxxhdpi/ic_flip_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..21eaff32a5ddf7a1a1047c33c9e54e2b80e5ec0d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_flip_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_forum_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_forum_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..cf0232f6f3c3f949244ae37448b66a00c38887c2 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_forum_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_help_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_help_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..45db279b342d3a4698b749eb1ca56df0004fad0b Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_help_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2ffdb55f264ecd3610f90890f8202f93c00f72e1 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..5bd56903d032fc7e43b8067aea0728ccefd67161 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..30948d98332e518808da6c4ca30894c582006e7c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_keyboard_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_keyboard_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..84365a57634273a7ee657fb5264b4c0df6e8ff8d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_keyboard_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..092346dd7cd9d7096476e544df490c524e688d3c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8ad36ced7da0dc6d8a45be799585138d0d117b27 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_location_off_white_24.png b/src/main/res/drawable-xxxhdpi/ic_location_off_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..0ee2e63676df9db1c2dcd5d5d90d54ebcf179f3d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_location_off_white_24.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8bcb6f620d5eef21f9322626451aedf0e83cb510 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_grey600_24dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_grey600_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..784bd8f4503ae7798e5161b0e9362d98c125f485 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_mic_grey600_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a11b5f6446b5ecc732d28653c6509bd49aef5772 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_mic_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..89f1608b1e4ca9191cfbe78add779e214663467d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_mic_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_notifications_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_notifications_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..09bd38714518dd2fd8143b0599cfed261563b88f Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_notifications_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ba4808673bc57d6f8bad610e055fc16df07a7de8 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..33d40d8b6246e4f62c3791c4ea59525ec5f2191c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f1fe74c154c85c6e31a937ea08e9d1a49b4202a6 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png b/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..23a9c2efd07537d261952851094d5dbc47b2f753 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_photo_camera_light.png b/src/main/res/drawable-xxxhdpi/ic_photo_camera_light.png new file mode 100644 index 0000000000000000000000000000000000000000..12b40dafe4d6d63ae6d389d230683dd9ba1b490d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_photo_camera_light.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_pin_white.png b/src/main/res/drawable-xxxhdpi/ic_pin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..8e604aa4f62e766ba7495ded2c4e397f0af596b0 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_pin_white.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d08a2ed51b3a2c6d1d4b202ac000f89384c47152 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ed85f50ab94bb216b8369d51e06458591d6a6d5f Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png b/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ff6ff92d81e116bdbdca35daf84f4d87ad878243 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp b/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..a41dcbe6647718f7bfdb51df23d452e623988032 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_send_push_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_send_push_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..79211258f55af6001857fd80803ced4f6cfc39c9 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_send_push_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_send_sms_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_send_sms_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..3eec4cf58cede972c6eaf293d6fab1384c4610d2 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_send_sms_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_swap_vert_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_swap_vert_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..2afe2a5f9f8d2787e7df1c89f222b9310abbfeca Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_swap_vert_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..e4eb4e3c6724c0d624a76655fdda7e82a1af1085 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_text_32.webp b/src/main/res/drawable-xxxhdpi/ic_text_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..19833035260e31e27385c461eb919c160d80b32d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_text_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp b/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..f9c3bd012f01780071806f2edfe6092001e62ae7 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..a789520baa7f4b65fd73ae78a57ec50b9d645e3c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_undo_32.webp b/src/main/res/drawable-xxxhdpi/ic_undo_32.webp new file mode 100644 index 0000000000000000000000000000000000000000..fd43a3324e28a7346fcb22e9d7927ff81ae61194 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_undo_32.webp differ diff --git a/src/main/res/drawable-xxxhdpi/ic_unpin_white.png b/src/main/res/drawable-xxxhdpi/ic_unpin_white.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e2f70210b5740af2d43c8c3eef23d030747f07 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_unpin_white.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png b/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..359e1aed804a5015a3b53c3b9b1efc1aa8a817df Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.png b/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.png new file mode 100644 index 0000000000000000000000000000000000000000..9f44de0527e2fddf7be8ff86dbd1932c169e4b56 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..97c34a0e0060120ca1b01a256396e6fa9e469496 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..1bc11a8bbb1a83c9caf549b7538c19b288773dc3 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png differ diff --git a/src/main/res/drawable/archived_indicator_background.xml b/src/main/res/drawable/archived_indicator_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..04660298495f003db906ab12ee1deccdc2ac00c5 --- /dev/null +++ b/src/main/res/drawable/archived_indicator_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/res/drawable/attachment_selector_shadow.xml b/src/main/res/drawable/attachment_selector_shadow.xml new file mode 100644 index 0000000000000000000000000000000000000000..34e2aa8cd47b96c4e76378f685ebcacedb0f7bbc --- /dev/null +++ b/src/main/res/drawable/attachment_selector_shadow.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/background_hd.jpg b/src/main/res/drawable/background_hd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34a4bec8d4c15b6378e3bd7785a2fd9966ffb92a --- /dev/null +++ b/src/main/res/drawable/background_hd.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df1fcd5cd511577737b69fb675129746c15d2608440689c338412f1f72cd23c4 +size 128777 diff --git a/src/main/res/drawable/badge_divider.xml b/src/main/res/drawable/badge_divider.xml new file mode 100644 index 0000000000000000000000000000000000000000..9db9b4665e15f8e3992184786bf26d442ed3d846 --- /dev/null +++ b/src/main/res/drawable/badge_divider.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/baseline_bookmark_24.xml b/src/main/res/drawable/baseline_bookmark_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..77251324b063acf9d53c9a41b1bb1732c45675a8 --- /dev/null +++ b/src/main/res/drawable/baseline_bookmark_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/baseline_bookmark_border_24.xml b/src/main/res/drawable/baseline_bookmark_border_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..d64b0ea27d976ea0b753a1dd68256fcfcbd8749a --- /dev/null +++ b/src/main/res/drawable/baseline_bookmark_border_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/baseline_bookmark_remove_24.xml b/src/main/res/drawable/baseline_bookmark_remove_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..1fb5c5a926bbe1ec48e72adbe476f2276bfb3074 --- /dev/null +++ b/src/main/res/drawable/baseline_bookmark_remove_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/baseline_call_24.xml b/src/main/res/drawable/baseline_call_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..2aba8addcbff4e7ebf1fe711ce9cea5c85f734f9 --- /dev/null +++ b/src/main/res/drawable/baseline_call_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/baseline_call_end_24.xml b/src/main/res/drawable/baseline_call_end_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..fa4cd2c10e942c8a8796855b6477149fc858eb58 --- /dev/null +++ b/src/main/res/drawable/baseline_call_end_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/button_bg.xml b/src/main/res/drawable/button_bg.xml new file mode 100644 index 0000000000000000000000000000000000000000..9e5b1093827f2d43ff7e430d36548b7fede35684 --- /dev/null +++ b/src/main/res/drawable/button_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/button_secondary_background.xml b/src/main/res/drawable/button_secondary_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..8fe84fca5d5ccf21611911bcd77d7bd9b8b2c865 --- /dev/null +++ b/src/main/res/drawable/button_secondary_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/res/drawable/circle_alpha.xml b/src/main/res/drawable/circle_alpha.xml new file mode 100644 index 0000000000000000000000000000000000000000..82142dd347061a5d6d613778ce3de23374183431 --- /dev/null +++ b/src/main/res/drawable/circle_alpha.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/circle_tintable.xml b/src/main/res/drawable/circle_tintable.xml new file mode 100644 index 0000000000000000000000000000000000000000..6c5c360635fdf8d51cccfbf3e3f38712a553438a --- /dev/null +++ b/src/main/res/drawable/circle_tintable.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/circle_touch_highlight_background.xml b/src/main/res/drawable/circle_touch_highlight_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..fe392b45e6cd34535821e2106f677126a0a82d13 --- /dev/null +++ b/src/main/res/drawable/circle_touch_highlight_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/circle_universal_overlay.xml b/src/main/res/drawable/circle_universal_overlay.xml new file mode 100644 index 0000000000000000000000000000000000000000..32c516245a542c210d76f63f44a20ff5fb62b118 --- /dev/null +++ b/src/main/res/drawable/circle_universal_overlay.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/main/res/drawable/circle_white.xml b/src/main/res/drawable/circle_white.xml new file mode 100644 index 0000000000000000000000000000000000000000..631057c468a1797ed1320e73dfc5fe8d442bd15c --- /dev/null +++ b/src/main/res/drawable/circle_white.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/main/res/drawable/compose_divider_background.xml b/src/main/res/drawable/compose_divider_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..0046fe4e43ab293af73e014d079cc424638dcbb0 --- /dev/null +++ b/src/main/res/drawable/compose_divider_background.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/main/res/drawable/contact_list_divider_dark.xml b/src/main/res/drawable/contact_list_divider_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..ab63ce24ad64959ba794d4dff09e67280898ff2a --- /dev/null +++ b/src/main/res/drawable/contact_list_divider_dark.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/res/drawable/contact_list_divider_light.xml b/src/main/res/drawable/contact_list_divider_light.xml new file mode 100644 index 0000000000000000000000000000000000000000..f28cf73f6c4f5a7c1c21a0e64ee538a49b601495 --- /dev/null +++ b/src/main/res/drawable/contact_list_divider_light.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/contact_photo_background.xml b/src/main/res/drawable/contact_photo_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..ff1153bf2a261816c680c02b4de371cfb5f8e3e1 --- /dev/null +++ b/src/main/res/drawable/contact_photo_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/conversation_attachment_close_circle.xml b/src/main/res/drawable/conversation_attachment_close_circle.xml new file mode 100644 index 0000000000000000000000000000000000000000..86741f86e3fe6a14b48636c88e023a6b8dcd5ba9 --- /dev/null +++ b/src/main/res/drawable/conversation_attachment_close_circle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/main/res/drawable/conversation_attachment_edit.xml b/src/main/res/drawable/conversation_attachment_edit.xml new file mode 100644 index 0000000000000000000000000000000000000000..a72bb14ed50e59a387d5adc7deb32b83b2ecd42e --- /dev/null +++ b/src/main/res/drawable/conversation_attachment_edit.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/src/main/res/drawable/conversation_item_background.xml b/src/main/res/drawable/conversation_item_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..a1e2111d1568b2947a6ba791f9cbde9376387140 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated.xml b/src/main/res/drawable/conversation_item_background_animated.xml new file mode 100644 index 0000000000000000000000000000000000000000..632882d7c3947f32a9c80b4fcb1e8870f1a237f3 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_blue.xml b/src/main/res/drawable/conversation_item_background_animated_blue.xml new file mode 100644 index 0000000000000000000000000000000000000000..d7af7cdbbecc181bac14e430a3f7e9354707bca3 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_gray.xml b/src/main/res/drawable/conversation_item_background_animated_gray.xml new file mode 100644 index 0000000000000000000000000000000000000000..825fb36559cad6f713682e12002a87dd9ddcca0c --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_gray.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_green.xml b/src/main/res/drawable/conversation_item_background_animated_green.xml new file mode 100644 index 0000000000000000000000000000000000000000..a1204f69dfcec8dd8924d7e589a54ab59d5bbf6f --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_green.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_pink.xml b/src/main/res/drawable/conversation_item_background_animated_pink.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb8f54f8570d0637110ba08cc149c9313b891f8d --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_pink.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_purple.xml b/src/main/res/drawable/conversation_item_background_animated_purple.xml new file mode 100644 index 0000000000000000000000000000000000000000..319cf63bb14d5792cf34ef35a2ad6db7d98180f3 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_purple.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_animated_red.xml b/src/main/res/drawable/conversation_item_background_animated_red.xml new file mode 100644 index 0000000000000000000000000000000000000000..c4e9681b91b0955b909fe2959454e73d39880af1 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_animated_red.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_blue.xml b/src/main/res/drawable/conversation_item_background_blue.xml new file mode 100644 index 0000000000000000000000000000000000000000..1660d3233299a14d23897cfa5cbe194e419d58e0 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_gray.xml b/src/main/res/drawable/conversation_item_background_gray.xml new file mode 100644 index 0000000000000000000000000000000000000000..2648813cb72c07fdbb93de4be25787b32008bafa --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_gray.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_green.xml b/src/main/res/drawable/conversation_item_background_green.xml new file mode 100644 index 0000000000000000000000000000000000000000..f0dc1d9bd6fa68ca37eccf28138799ae3a0209fe --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_green.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_pink.xml b/src/main/res/drawable/conversation_item_background_pink.xml new file mode 100644 index 0000000000000000000000000000000000000000..48b31c9229e0002becfdaac2e9039ff9061ab174 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_pink.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_purple.xml b/src/main/res/drawable/conversation_item_background_purple.xml new file mode 100644 index 0000000000000000000000000000000000000000..b0463a4912558f802f0ebd2314b324cd8806b1f1 --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_purple.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_background_red.xml b/src/main/res/drawable/conversation_item_background_red.xml new file mode 100644 index 0000000000000000000000000000000000000000..489892e73e50f6bba1f7217f2dd9c3e92f16260c --- /dev/null +++ b/src/main/res/drawable/conversation_item_background_red.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/conversation_item_sent_indicator_text_shape.xml b/src/main/res/drawable/conversation_item_sent_indicator_text_shape.xml new file mode 100644 index 0000000000000000000000000000000000000000..59da5934aa0cf87f13d5d60a10f8ca20281e82c2 --- /dev/null +++ b/src/main/res/drawable/conversation_item_sent_indicator_text_shape.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml b/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..d02a3915615a9c80f77fc61e949addc949eb379b --- /dev/null +++ b/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/drawable/conversation_item_update_background.xml b/src/main/res/drawable/conversation_item_update_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..6a7c72c1142e5d94a22561738d8605a84d16d904 --- /dev/null +++ b/src/main/res/drawable/conversation_item_update_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/main/res/drawable/conversation_list_divider_shape.xml b/src/main/res/drawable/conversation_list_divider_shape.xml new file mode 100644 index 0000000000000000000000000000000000000000..459e9420aec5a60fa7e5afce1a8b4d7799235234 --- /dev/null +++ b/src/main/res/drawable/conversation_list_divider_shape.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_divider_shape_dark.xml b/src/main/res/drawable/conversation_list_divider_shape_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..87feb514854431ba9a3523e65de49f920a16ac62 --- /dev/null +++ b/src/main/res/drawable/conversation_list_divider_shape_dark.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background.xml b/src/main/res/drawable/conversation_list_item_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..82a85b42000f49e9cf21ec6079d10643dcec28e2 --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_blue.xml b/src/main/res/drawable/conversation_list_item_background_blue.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4b4d54a681fdba6759e38c9810ccbad98bbf3b3 --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_blue.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_gray.xml b/src/main/res/drawable/conversation_list_item_background_gray.xml new file mode 100644 index 0000000000000000000000000000000000000000..55e2c74fbcc5d4918a22a1b0f55123eddfa1e44d --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_gray.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_green.xml b/src/main/res/drawable/conversation_list_item_background_green.xml new file mode 100644 index 0000000000000000000000000000000000000000..cd4e19115b639eb2e342dfe21985434f5b5541a5 --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_green.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_pink.xml b/src/main/res/drawable/conversation_list_item_background_pink.xml new file mode 100644 index 0000000000000000000000000000000000000000..07e223232fec1bb788f896686e4cc7246a3b068f --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_pink.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_purple.xml b/src/main/res/drawable/conversation_list_item_background_purple.xml new file mode 100644 index 0000000000000000000000000000000000000000..010a9a07e93c6ed39666719e62df74b3b5b34c44 --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_purple.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/conversation_list_item_background_red.xml b/src/main/res/drawable/conversation_list_item_background_red.xml new file mode 100644 index 0000000000000000000000000000000000000000..1a6dcfd4f75d33fa9d39652644abd05d071fe178 --- /dev/null +++ b/src/main/res/drawable/conversation_list_item_background_red.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/res/drawable/delete_account_item_background.xml b/src/main/res/drawable/delete_account_item_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..583b62b943c25b7dc9e7a8d78bb4fea9f01cfa63 --- /dev/null +++ b/src/main/res/drawable/delete_account_item_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/main/res/drawable/dismiss_background.xml b/src/main/res/drawable/dismiss_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..d373de262b7ac25742da468d66a83bad85c342f8 --- /dev/null +++ b/src/main/res/drawable/dismiss_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/divider_end.xml b/src/main/res/drawable/divider_end.xml new file mode 100644 index 0000000000000000000000000000000000000000..be4c65380e2198324538d9222c541c5cbafd41fd --- /dev/null +++ b/src/main/res/drawable/divider_end.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/main/res/drawable/divider_start.xml b/src/main/res/drawable/divider_start.xml new file mode 100644 index 0000000000000000000000000000000000000000..220c9aba0f6ab7b65cbcb808c3e1515c676041bd --- /dev/null +++ b/src/main/res/drawable/divider_start.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/main/res/drawable/floating_mini_bg_dark.xml b/src/main/res/drawable/floating_mini_bg_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..617e35d5e5144aed2c63c6de9940269555cc86c1 --- /dev/null +++ b/src/main/res/drawable/floating_mini_bg_dark.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/src/main/res/drawable/floating_mini_bg_light.xml b/src/main/res/drawable/floating_mini_bg_light.xml new file mode 100644 index 0000000000000000000000000000000000000000..3b24b52c74b58be199919c48cb0dce4bba41d60b --- /dev/null +++ b/src/main/res/drawable/floating_mini_bg_light.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/src/main/res/drawable/ic_advanced_24dp.xml b/src/main/res/drawable/ic_advanced_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..4fb9e1ab03d2dfed9f3c32a73da410ed06f3ef9b --- /dev/null +++ b/src/main/res/drawable/ic_advanced_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_alternate_email_24.xml b/src/main/res/drawable/ic_alternate_email_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..e6182db41eefa323c804cf7e9da10f0211600321 --- /dev/null +++ b/src/main/res/drawable/ic_alternate_email_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_apps_24.xml b/src/main/res/drawable/ic_apps_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..f578daeb2fc548bdd82121c766d892be4b71618f --- /dev/null +++ b/src/main/res/drawable/ic_apps_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/ic_baseline_devices_24.xml b/src/main/res/drawable/ic_baseline_devices_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..28a7185062d6a04aa96c646298e89e06dca082ae --- /dev/null +++ b/src/main/res/drawable/ic_baseline_devices_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_blur_on_white_24.png b/src/main/res/drawable/ic_blur_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..cab73449bf4ad907ee8a3aa07b00cd91863bc86d Binary files /dev/null and b/src/main/res/drawable/ic_blur_on_white_24.png differ diff --git a/src/main/res/drawable/ic_brightness_6_24dp.xml b/src/main/res/drawable/ic_brightness_6_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..548ad986f45155c28a3871b233a53ef12ff0c300 --- /dev/null +++ b/src/main/res/drawable/ic_brightness_6_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_chevron_up.xml b/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 0000000000000000000000000000000000000000..c54db3bcc63be57e2feb35282f8b291b5514982b --- /dev/null +++ b/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_circle_status_online.xml b/src/main/res/drawable/ic_circle_status_online.xml new file mode 100644 index 0000000000000000000000000000000000000000..b481cbb186cc07b57105406d33100dd7b06da601 --- /dev/null +++ b/src/main/res/drawable/ic_circle_status_online.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/main/res/drawable/ic_delivery_status_failed.xml b/src/main/res/drawable/ic_delivery_status_failed.xml new file mode 100644 index 0000000000000000000000000000000000000000..fbd4009fd97d426fdd625956df751279c1371c38 --- /dev/null +++ b/src/main/res/drawable/ic_delivery_status_failed.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_donate_24dp.xml b/src/main/res/drawable/ic_donate_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..07ab6aae9888fba1fd147c7cd81a81dc00816bc3 --- /dev/null +++ b/src/main/res/drawable/ic_donate_24dp.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/ic_forum_24dp.xml b/src/main/res/drawable/ic_forum_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..e48c05fd42514ab7dad70c31b3b52440db1f90ba --- /dev/null +++ b/src/main/res/drawable/ic_forum_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_forward_white_24dp.xml b/src/main/res/drawable/ic_forward_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..4b84d9a421a8254b6c12bd3d23ada621aafc3c75 --- /dev/null +++ b/src/main/res/drawable/ic_forward_white_24dp.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/ic_help_24dp.xml b/src/main/res/drawable/ic_help_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..66449b72fd8e3dc562e265fea6d1a81ab9238d91 --- /dev/null +++ b/src/main/res/drawable/ic_help_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_launcher_foreground.xml b/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000000000000000000000000000000000..22648ce297fac2d71ece63ac14ca840e0065ce03 --- /dev/null +++ b/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/src/main/res/drawable/ic_launcher_foreground_monochrome.xml new file mode 100644 index 0000000000000000000000000000000000000000..8ef65e2a816c7fbc7f90fda95a051dfc476e0486 --- /dev/null +++ b/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/src/main/res/drawable/ic_link_24.xml b/src/main/res/drawable/ic_link_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..54d9934a33b4675ec4c62b0aa40a5bfabdb5ece5 --- /dev/null +++ b/src/main/res/drawable/ic_link_24.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/main/res/drawable/ic_lock.xml b/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000000000000000000000000000000000000..b642ae75e597fbafc1f7532ba7d859504f926108 --- /dev/null +++ b/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_lock_24dp.xml b/src/main/res/drawable/ic_lock_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..da2a77be2ecf85a3bab8f6577bd1ce73a3ee4774 --- /dev/null +++ b/src/main/res/drawable/ic_lock_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_map_white_24dp.xml b/src/main/res/drawable/ic_map_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..efd6dc37e505940e1e15d9ea37421f4bb6acfc2d --- /dev/null +++ b/src/main/res/drawable/ic_map_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_notifications_24dp.xml b/src/main/res/drawable/ic_notifications_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..56cd14d7a2fb7761eef432ce2ebe0d683fa83cc1 --- /dev/null +++ b/src/main/res/drawable/ic_notifications_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_outline_email.xml b/src/main/res/drawable/ic_outline_email.xml new file mode 100644 index 0000000000000000000000000000000000000000..19ab62ba49901a98b713786476423bc3650712ab --- /dev/null +++ b/src/main/res/drawable/ic_outline_email.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_person_large.xml b/src/main/res/drawable/ic_person_large.xml new file mode 100644 index 0000000000000000000000000000000000000000..c3324be1de99ec2002a3ef707fb809301b76ed98 --- /dev/null +++ b/src/main/res/drawable/ic_person_large.xml @@ -0,0 +1,4 @@ + + + diff --git a/src/main/res/drawable/ic_proxy_disabled_24.xml b/src/main/res/drawable/ic_proxy_disabled_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..287bc46d2a9ed1adafe7a64c668cd491d6ab129e --- /dev/null +++ b/src/main/res/drawable/ic_proxy_disabled_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/ic_proxy_enabled_24.xml b/src/main/res/drawable/ic_proxy_enabled_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..81c20e883d0fb423072ad14cd1f127a045778248 --- /dev/null +++ b/src/main/res/drawable/ic_proxy_enabled_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/ic_qr_code_24.xml b/src/main/res/drawable/ic_qr_code_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..f95c18d61557b3070005ea6bc60aae942e282e2a --- /dev/null +++ b/src/main/res/drawable/ic_qr_code_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/ic_qr_code_scanner_24.xml b/src/main/res/drawable/ic_qr_code_scanner_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..725b35ea7fcbf0f39285483b481c0f731a517eba --- /dev/null +++ b/src/main/res/drawable/ic_qr_code_scanner_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/ic_share_white_24dp.xml b/src/main/res/drawable/ic_share_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..045bbc0c0e0460bb7b515c1d80914a253bc7efc3 --- /dev/null +++ b/src/main/res/drawable/ic_share_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_swap_vert_24dp.xml b/src/main/res/drawable/ic_swap_vert_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..c54dc343d654609b92bd988f909702c536271c62 --- /dev/null +++ b/src/main/res/drawable/ic_swap_vert_24dp.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/drawable/ic_timer_gray_18dp.xml b/src/main/res/drawable/ic_timer_gray_18dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..52a5c921870f620b03037e1b9683d41f227a8447 --- /dev/null +++ b/src/main/res/drawable/ic_timer_gray_18dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/ic_verified.xml b/src/main/res/drawable/ic_verified.xml new file mode 100644 index 0000000000000000000000000000000000000000..c8b706df6981be43e84a73c801ad107ebfcfa153 --- /dev/null +++ b/src/main/res/drawable/ic_verified.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/main/res/drawable/ic_videocam_white_24dp.xml b/src/main/res/drawable/ic_videocam_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..f75fa71aefca061ab56e065da83afb1a5b57788d --- /dev/null +++ b/src/main/res/drawable/ic_videocam_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/main/res/drawable/image_shade.xml b/src/main/res/drawable/image_shade.xml new file mode 100644 index 0000000000000000000000000000000000000000..07a18cb4df9fc9243b01c9735a10730624026451 --- /dev/null +++ b/src/main/res/drawable/image_shade.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/intro1.png b/src/main/res/drawable/intro1.png new file mode 100644 index 0000000000000000000000000000000000000000..b44a7879c73d7708b0c36cd113e336b8c7ec7b1f --- /dev/null +++ b/src/main/res/drawable/intro1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aac6920c8fdc9774c1320dbd704bcd6007888fc76faa58533f980e8e2236e460 +size 440616 diff --git a/src/main/res/drawable/jumpto_btn_bg.xml b/src/main/res/drawable/jumpto_btn_bg.xml new file mode 100644 index 0000000000000000000000000000000000000000..82142dd347061a5d6d613778ce3de23374183431 --- /dev/null +++ b/src/main/res/drawable/jumpto_btn_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/message_bubble_background_received_alone.xml b/src/main/res/drawable/message_bubble_background_received_alone.xml new file mode 100644 index 0000000000000000000000000000000000000000..196688a09c7e2bd588dfbd65afbe9eeefa6463ef --- /dev/null +++ b/src/main/res/drawable/message_bubble_background_received_alone.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/message_bubble_background_sent_alone.xml b/src/main/res/drawable/message_bubble_background_sent_alone.xml new file mode 100644 index 0000000000000000000000000000000000000000..fff6b14b63a440a5f84e0022b50d6953c76d5614 --- /dev/null +++ b/src/main/res/drawable/message_bubble_background_sent_alone.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/message_bubble_background_sent_alone_with_border.xml b/src/main/res/drawable/message_bubble_background_sent_alone_with_border.xml new file mode 100644 index 0000000000000000000000000000000000000000..e8ac29c9aac1aafadaff814a6b7fdfc4823c7aa0 --- /dev/null +++ b/src/main/res/drawable/message_bubble_background_sent_alone_with_border.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pause_icon.xml b/src/main/res/drawable/pause_icon.xml new file mode 100644 index 0000000000000000000000000000000000000000..e7f9fce53e8efdcb5aba77048144345f40908f95 --- /dev/null +++ b/src/main/res/drawable/pause_icon.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/pause_to_play_animation.xml b/src/main/res/drawable/pause_to_play_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..aa010cee96570da88d732b29f32e5493f7293edf --- /dev/null +++ b/src/main/res/drawable/pause_to_play_animation.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background.xml b/src/main/res/drawable/pinned_list_item_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..0df99189de015d3a5f745e84fbb7f2d9a4044eff --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_blue.xml b/src/main/res/drawable/pinned_list_item_background_blue.xml new file mode 100644 index 0000000000000000000000000000000000000000..ca3c9c8fd48366a9b2d2415c034c7f055f536f13 --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_blue.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_gray.xml b/src/main/res/drawable/pinned_list_item_background_gray.xml new file mode 100644 index 0000000000000000000000000000000000000000..ae332ca2f9a4e791af160f6274d1c42f630a16b9 --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_gray.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_green.xml b/src/main/res/drawable/pinned_list_item_background_green.xml new file mode 100644 index 0000000000000000000000000000000000000000..3264071d4f6035d6686ef4e795bf43fa4165c1b2 --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_green.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_pink.xml b/src/main/res/drawable/pinned_list_item_background_pink.xml new file mode 100644 index 0000000000000000000000000000000000000000..7279114a9a8b7842070d90169d4c6f7c59a567ba --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_pink.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_purple.xml b/src/main/res/drawable/pinned_list_item_background_purple.xml new file mode 100644 index 0000000000000000000000000000000000000000..5761632a4e66c56ac7d4a56ad8685e4be6162e12 --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_purple.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/pinned_list_item_background_red.xml b/src/main/res/drawable/pinned_list_item_background_red.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e30584d40f424226eac0d3e752d20effb471f16 --- /dev/null +++ b/src/main/res/drawable/pinned_list_item_background_red.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/play_icon.xml b/src/main/res/drawable/play_icon.xml new file mode 100644 index 0000000000000000000000000000000000000000..7472ac7de07ed878f8b82bbb865c8ff91a566b88 --- /dev/null +++ b/src/main/res/drawable/play_icon.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/play_to_pause_animation.xml b/src/main/res/drawable/play_to_pause_animation.xml new file mode 100644 index 0000000000000000000000000000000000000000..173fed50f03202ed5a5eaf05335a0ecdbc5e446c --- /dev/null +++ b/src/main/res/drawable/play_to_pause_animation.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/main/res/drawable/reaction_pill_background.xml b/src/main/res/drawable/reaction_pill_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..bad81d54c58c4d9352f3e779d77acf8b76b7b470 --- /dev/null +++ b/src/main/res/drawable/reaction_pill_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/res/drawable/reaction_pill_background_selected.xml b/src/main/res/drawable/reaction_pill_background_selected.xml new file mode 100644 index 0000000000000000000000000000000000000000..b52af9b3d29f61fdb9f1ec33d651ae2f5f5c56ff --- /dev/null +++ b/src/main/res/drawable/reaction_pill_background_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/res/drawable/recording_lock_background_dark.xml b/src/main/res/drawable/recording_lock_background_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..5dda37904bd11d3e28a1c71094ce3908511f0088 --- /dev/null +++ b/src/main/res/drawable/recording_lock_background_dark.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/main/res/drawable/recording_lock_background_light.xml b/src/main/res/drawable/recording_lock_background_light.xml new file mode 100644 index 0000000000000000000000000000000000000000..d2e3a753e0b154036fd9381f6874e86876d58368 --- /dev/null +++ b/src/main/res/drawable/recording_lock_background_light.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/main/res/drawable/rounded_arrow_forward_24.xml b/src/main/res/drawable/rounded_arrow_forward_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..29b2576d469024502663cf32c5686c681301f850 --- /dev/null +++ b/src/main/res/drawable/rounded_arrow_forward_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/main/res/drawable/search_toolbar_shadow.xml b/src/main/res/drawable/search_toolbar_shadow.xml new file mode 100644 index 0000000000000000000000000000000000000000..5afdc2a2df5eee131ced80eb76e7c8da8f4172fc --- /dev/null +++ b/src/main/res/drawable/search_toolbar_shadow.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/src/main/res/drawable/send_button_bg.xml b/src/main/res/drawable/send_button_bg.xml new file mode 100644 index 0000000000000000000000000000000000000000..1286661935f7ef7c6ff3d4fae0d8210d1010e46a --- /dev/null +++ b/src/main/res/drawable/send_button_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/src/main/res/drawable/sticker_missing_background.xml b/src/main/res/drawable/sticker_missing_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..c9ce98f2bffb995ad6ebf66af7985ff156c9da6c --- /dev/null +++ b/src/main/res/drawable/sticker_missing_background.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/main/res/drawable/sticky_date_header_background_dark.xml b/src/main/res/drawable/sticky_date_header_background_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..d5be2c288c19eb902f35bfc0cf8d148099452bd3 --- /dev/null +++ b/src/main/res/drawable/sticky_date_header_background_dark.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/sticky_date_header_background_light.xml b/src/main/res/drawable/sticky_date_header_background_light.xml new file mode 100644 index 0000000000000000000000000000000000000000..b015b381ba843b1e821a8d032ca696287111f68d --- /dev/null +++ b/src/main/res/drawable/sticky_date_header_background_light.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/touch_highlight_background.xml b/src/main/res/drawable/touch_highlight_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..3c7a58ba7ef8c88f5bdee75edeb4796474c420f0 --- /dev/null +++ b/src/main/res/drawable/touch_highlight_background.xml @@ -0,0 +1,7 @@ + + + + diff --git a/src/main/res/drawable/triangle_right.xml b/src/main/res/drawable/triangle_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..d6f003ef826c53a28120b8580e23ab7ab1fb3ccf --- /dev/null +++ b/src/main/res/drawable/triangle_right.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml b/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml new file mode 100644 index 0000000000000000000000000000000000000000..9417360bfb30bf63f326768eedd6c3904b1c9415 --- /dev/null +++ b/src/main/res/layout-land/conversation_activity_emojidrawer_stub.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/src/main/res/layout-land/reaction_picker.xml b/src/main/res/layout-land/reaction_picker.xml new file mode 100644 index 0000000000000000000000000000000000000000..0115e91faf5c4aee3139c7fce32255e419edd44a --- /dev/null +++ b/src/main/res/layout-land/reaction_picker.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/src/main/res/layout/account_selection_list_fragment.xml b/src/main/res/layout/account_selection_list_fragment.xml new file mode 100644 index 0000000000000000000000000000000000000000..dfe5274fad1e2a2e8a55ef0725c95aad6600f1c3 --- /dev/null +++ b/src/main/res/layout/account_selection_list_fragment.xml @@ -0,0 +1,7 @@ + diff --git a/src/main/res/layout/account_selection_list_item.xml b/src/main/res/layout/account_selection_list_item.xml new file mode 100644 index 0000000000000000000000000000000000000000..0192093b8c71d8f51c0063bb25dee5203cd80ac5 --- /dev/null +++ b/src/main/res/layout/account_selection_list_item.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_application_preferences.xml b/src/main/res/layout/activity_application_preferences.xml new file mode 100644 index 0000000000000000000000000000000000000000..acddaa01c018f9879c9723511cdd4572829c9122 --- /dev/null +++ b/src/main/res/layout/activity_application_preferences.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/main/res/layout/activity_blocked_contacts.xml b/src/main/res/layout/activity_blocked_contacts.xml new file mode 100644 index 0000000000000000000000000000000000000000..acddaa01c018f9879c9723511cdd4572829c9122 --- /dev/null +++ b/src/main/res/layout/activity_blocked_contacts.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/main/res/layout/activity_conversation_list_archive.xml b/src/main/res/layout/activity_conversation_list_archive.xml new file mode 100644 index 0000000000000000000000000000000000000000..acddaa01c018f9879c9723511cdd4572829c9122 --- /dev/null +++ b/src/main/res/layout/activity_conversation_list_archive.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/main/res/layout/activity_edittransport.xml b/src/main/res/layout/activity_edittransport.xml new file mode 100644 index 0000000000000000000000000000000000000000..3e393d856af0e868dd783c434a1c404ea786fc1f --- /dev/null +++ b/src/main/res/layout/activity_edittransport.xml @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_qr.xml b/src/main/res/layout/activity_qr.xml new file mode 100644 index 0000000000000000000000000000000000000000..68530d76c46eecfc0b0c1182d88c02c181adb027 --- /dev/null +++ b/src/main/res/layout/activity_qr.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_qr_show.xml b/src/main/res/layout/activity_qr_show.xml new file mode 100644 index 0000000000000000000000000000000000000000..473de096837dac408fe4d1fd40395d3528228faf --- /dev/null +++ b/src/main/res/layout/activity_qr_show.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/src/main/res/layout/activity_registration_2nd_device_qr.xml b/src/main/res/layout/activity_registration_2nd_device_qr.xml new file mode 100644 index 0000000000000000000000000000000000000000..01241e2b598189ccc0126b5e0d9a2462bb53cbd6 --- /dev/null +++ b/src/main/res/layout/activity_registration_2nd_device_qr.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_registration_qr.xml b/src/main/res/layout/activity_registration_qr.xml new file mode 100644 index 0000000000000000000000000000000000000000..95880e3dae1878313c3f27ba3e217c1f4a62ab18 --- /dev/null +++ b/src/main/res/layout/activity_registration_qr.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_relay_list.xml b/src/main/res/layout/activity_relay_list.xml new file mode 100644 index 0000000000000000000000000000000000000000..243fc3ccbd83c7d4c1a76206bce85344460cc0e8 --- /dev/null +++ b/src/main/res/layout/activity_relay_list.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/src/main/res/layout/activity_select_chat_background.xml b/src/main/res/layout/activity_select_chat_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..e61fb0b58ea4c549d8dda62897a64cea97f2d6d6 --- /dev/null +++ b/src/main/res/layout/activity_select_chat_background.xml @@ -0,0 +1,48 @@ + + + + + + + + + +