diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..f7db64b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,22 @@
+---
+name: Bug report
+about: Something doesn't work as expected
+labels: bug
+---
+
+**What happened?**
+
+**What did you expect to happen?**
+
+**Steps to reproduce**
+
+1.
+2.
+
+**Environment**
+
+- Clipster version:
+- Android version and device:
+- Server: public (clipster.cc) / own server
+
+
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..fcb0e2e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Report a security vulnerability
+ url: https://github.com/mc51/Clipster-Android/security/advisories/new
+ about: Please report vulnerabilities privately, not as a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..7cc2c7a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,9 @@
+---
+name: Feature request
+about: Suggest an idea for Clipster
+labels: enhancement
+---
+
+**What problem would this solve?**
+
+**How would you like it to work?**
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..7fa54a6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,17 @@
+version: 2
+updates:
+ - package-ecosystem: gradle
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ gradle-dependencies:
+ patterns: ["*"]
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ github-actions:
+ patterns: ["*"]
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..445e5fd
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,6 @@
+**What does this change and why?**
+
+**How did you test it?**
+
+- [ ] `./gradlew spotlessCheck lint testDebugUnitTest assembleRelease` passes
+- [ ] Tested on a device or emulator (Android version: )
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 209932d..730e03e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,6 +16,9 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v7
+ with:
+ # Tags are needed to derive the version for manual (non-tag) runs
+ fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@v6
@@ -46,8 +49,14 @@ jobs:
echo "keyPassword=$KEY_PASSWORD"
} > secret
- - name: Build the app
- run: ./gradlew assembleRelease
+ # versionName and versionCode are derived from the tag, see app/build.gradle
+ - name: Test and build the app
+ run: |
+ version_args=()
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ version_args+=("-PappVersion=$GITHUB_REF_NAME")
+ fi
+ ./gradlew "${version_args[@]}" testDebugUnitTest assembleRelease
- name: Verify signature
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..24d77ad
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ name: Lint, test and build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout the code
+ uses: actions/checkout@v7
+
+ - name: Set up JDK
+ uses: actions/setup-java@v6
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ # Unsigned release build: runs R8 like a real release, but needs no signing
+ # secrets, so this also works for pull requests from forks
+ - name: Formatting, lint, unit tests and release build
+ run: ./gradlew spotlessCheck lint testDebugUnitTest assembleRelease
+
+ - name: Upload reports
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: reports
+ path: |
+ app/build/reports/
+ app/build/test-results/
diff --git a/.gitignore b/.gitignore
index d8e014c..b64e47a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,111 +1,38 @@
-!gradle/wrapper/gradle-wrapper.properties
-.idea/
-*.iml
-# .gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-*tmp/
-.DS_Store
-/build
-/captures
-*reports/
-.externalNativeBuild
-.cxx
-*.apk
+# Gradle and build output
+.gradle/
+build/
+.kotlin/
+.externalNativeBuild/
+.cxx/
+captures/
+
+# Local configuration (SDK path etc.)
+local.properties
+
+# Signing: never commit keystores or the signing config (see README)
+secret
*.jks
-*secret*
-# *.jar
+*.keystore
-# Built application files
+# Built artifacts
*.apk
+*.aab
*.aar
*.ap_
-*.aab
-
-# Files for the ART/Dalvik VM
*.dex
-
-# Java class files
*.class
-# Generated files
-bin/
-gen/
-out/
-# Uncomment the following line in case you need and you don't have the release build type files in your app
-# release/
-
-# Gradle files
-.gradle/
-build/
-
-# Local configuration file (sdk path, etc)
-local.properties
-
-# Proguard folder generated by Eclipse
-proguard/
-
-# Log Files
-*.log
-
-# Android Studio Navigation editor temp files
-.navigation/
-
-# Android Studio captures folder
-captures/
-
-# IntelliJ
+# IDE
+.idea/
*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
-.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-# Android Studio 3 in .gitignore file.
-.idea/caches
-.idea/modules.xml
-# Comment next line if keeping position of elements in Navigation Editor is relevant for you
-.idea/navEditor.xml
+.vscode/
-# Keystore files
-# Uncomment the following lines if you do not want to check your keystore files in.
-#*.jks
-#*.keystore
-
-# External native build folder generated in Android Studio 2.2 and later
-.externalNativeBuild
-.cxx/
-
-# Google Services (e.g. APIs or Firebase)
-# google-services.json
-
-# Freeline
-freeline.py
-freeline/
-freeline_project_description.json
+# OS and logs
+.DS_Store
+*.log
+*.hprof
-# fastlane
+# Fastlane output (metadata in fastlane/metadata is committed)
fastlane/report.xml
fastlane/Preview.html
-fastlane/screenshots
-fastlane/test_output
-fastlane/readme.md
-
-# Version control
-vcs.xml
-
-# lint
-lint/intermediates/
-lint/generated/
-lint/outputs/
-lint/tmp/
-# lint/reports/
-
-# Android Profiling
-*.hprof
+fastlane/test_output/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c96a783
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,44 @@
+# Contributing
+
+Thanks for helping! Bug reports and feature requests go to the [issues](https://github.com/mc51/Clipster-Android/issues), code changes to pull requests against `master`.
+
+## Development setup
+
+- JDK 17 or newer and the Android SDK (Android Studio brings both)
+- `./gradlew assembleDebug` builds `app/build/outputs/apk/debug/app-debug.apk`
+
+## Before opening a pull request
+
+Run what CI runs:
+
+```sh
+./gradlew spotlessCheck lint testDebugUnitTest assembleRelease
+```
+
+- **Formatting:** `./gradlew spotlessApply` formats Java with [palantir-java-format](https://github.com/palantir/palantir-java-format).
+- **Lint:** warnings fail the build. Fix them, or suppress a false positive with `@SuppressLint` / `tools:ignore` and a comment explaining why.
+- **Tests:** JVM unit tests live in `app/src/test`. `CryptoTest` checks compatibility with the other Clipster clients: if it fails, Android can no longer read clips from them.
+
+Dependencies are managed in `gradle/libs.versions.toml` and updated by Dependabot.
+
+## Releases
+
+The git tag is the only place the version is kept. `versionName` and `versionCode` are derived from it (`v0.7.2` becomes `0.7.2` / `702`):
+
+```sh
+git tag v0.7.0
+git push origin v0.7.0
+```
+
+The [Release workflow](.github/workflows/build.yml) then builds, signs and publishes `clipster.apk`. Signing needs these repository secrets: `SIGNING_KEY` (base64 of the keystore), `ALIAS`, `KEY_STORE_PASSWORD` and `KEY_PASSWORD`.
+
+To sign release builds locally, create a file named `secret` in the project root (it's git-ignored):
+
+```properties
+storeFile=/path/to/clipster.jks
+storePassword=...
+keyAlias=...
+keyPassword=...
+```
+
+Without it, `assembleRelease` builds an unsigned APK.
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..d2e5827
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,21 @@
+# Privacy Policy
+
+Clipster contains no ads, analytics or trackers. It only talks to the Clipster server you configured: the public server at [clipster.cc](https://clipster.cc) or your own.
+
+## Data sent to the server
+
+- Your username and a hash of your password (used to log in, your password itself is never sent)
+- Your clips, encrypted on your device. The server can't read them.
+- For each clip: its format (text or image), the name of your device and the time it was shared
+
+The server keeps only your newest clips (5 on the public server) and deletes older ones.
+
+## Data on your device
+
+Your username, server address and the hashes derived from your password are stored in the app's private storage. They are not included in backups. Uninstalling the app or clearing its data removes them.
+
+## Permissions
+
+- **Internet:** to reach the Clipster server
+- **Nearby devices** (Android 17+, only if your server is on your local network): to reach it
+- **Storage** (Android 9 and older, only when you save an image): to save it to the gallery
diff --git a/README.md b/README.md
index b2fe283..7e6d80e 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,56 @@
# Clipster - Android Client
-[](https://github.com/mc51/Clipster-Android/actions)
-
-Clipster is a multi platform cloud clipboard:
-Copy a text or image on your smartphone and paste it on your desktop, or vice versa.
-Easy, secure, open source.
-Supports Android, Linux, MacOS, Windows and all browsers.
-
-This is the Android Client.
-There also is a [Clipster-Desktop](https://github.com/mc51/Clipster-Desktop) client for Linux, MacOS and Windows.
-You can run your own [Clipster-Server](https://github.com/mc51/Clipster-Server) on a Linux machine.
-Or you can use the public server at [https://clipster.cc](https://clipster.cc).
-
-
+[](https://github.com/mc51/Clipster-Android/actions/workflows/ci.yml)
+[](https://github.com/mc51/Clipster-Android/actions/workflows/build.yml)
+
+Clipster is a multi platform cloud clipboard:
+Copy a text or image on your smartphone and paste it on your desktop, or vice versa.
+Easy, secure, open source.
+Supports Android, Linux, MacOS, Windows and all browsers.
+
+This is the Android Client.
+There also is a [Clipster-Desktop](https://github.com/mc51/Clipster-Desktop) client for Linux, MacOS and Windows.
+You can run your own [Clipster-Server](https://github.com/mc51/Clipster-Server) on a Linux machine.
+Or you can use the public server at [https://clipster.cc](https://clipster.cc).
+
+
## Setup
-Download the latest [clipster.apk](https://github.com/mc51/Clipster-Android/releases/latest/download/clipster.apk) release to your Android device. Make sure you can run third-party apps on your device. Check the "Security" tab or listing within your Settings menu. Normally it’ll say allow third-party installations or install from unknown sources.
+Download the latest [clipster.apk](https://github.com/mc51/Clipster-Android/releases/latest/download/clipster.apk) to your Android device (Android 8 or newer) and install it. You may have to allow installing apps from unknown sources in your settings.
## Usage
-On the first run, register a new account. The (hashed) credentials are saved on your device.
-To share a text with your other devices, long-press and share it to `Clipster` or copy it to the clipboard and `Share Clip` in the app.
-To get a text that was shared from one of your devices, select `Get last Clip` or `Get all Clips` in the app.
+- On the first run, register a new account or log in. Leave the server empty to use the public server.
+- **Share:** long-press a text or image, share it to `Clipster`. Or copy a text and tap `Share Clipboard`.
+- **Get:** tap `Get last Clip` to copy the newest clip to your clipboard, or `Get all Clips` to pick one. Long-press the app icon for a `Get last Clip` shortcut.
+
+### Own server with a self-signed certificate
+
+Enable `Allow self-signed certificate` on the login screen. On the first connection Clipster shows the certificate's SHA-256 fingerprint. Only trust it if it matches your server's:
+
+```sh
+openssl x509 -noout -fingerprint -sha256 -in cert.pem
+```
+
+Clipster then only accepts exactly this certificate and warns you if it ever changes. On Android 17 and newer, Clipster asks for the "Nearby devices" permission if your server is on your local network.
+
+## Security
+
+Clips are encrypted on your device before they are sent, the server only stores encrypted data. Your password never leaves the device. See [SECURITY.md](SECURITY.md) for how this works, its limits, and how to report vulnerabilities, and [PRIVACY.md](PRIVACY.md) for the data the server sees.
+
+## Development
+
+Needs JDK 17+ and the Android SDK. Build a debug APK with `./gradlew assembleDebug`. See [CONTRIBUTING.md](CONTRIBUTING.md) for tests, formatting and releases.
## Roadmap
- [x] Encrypt / Decrypt clipboard locally and only transmit encrypted data to server
- [x] Add clipboard history: share multiple Clips
- [x] Support image sharing
-- [ ] F-Store release
+- [ ] F-Droid release
- [ ] Google Play Store release
-
+
## Contributions
-Contributions are very welcome. If you come across a bug, please open an issue. The same thing goes for feature requests.
+Contributions are very welcome. If you come across a bug, please open an issue. The same goes for feature requests.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..a19f142
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,17 @@
+# Security
+
+## Reporting a vulnerability
+
+Please don't open a public issue. Report it privately via [GitHub's vulnerability reporting](https://github.com/mc51/Clipster-Android/security/advisories/new) instead.
+
+## How Clipster protects your clips
+
+- **Encryption on the device:** clips are encrypted with [Fernet](https://github.com/fernet/spec) (AES-128-CBC with HMAC-SHA256) before they are sent. The server only stores encrypted clips, the key never leaves your devices.
+- **Keys from your password:** Clipster derives two PBKDF2-SHA256 hashes from your password. One (20,000 iterations) is your login for the server. The other (10,000 iterations) is the encryption key. Your password itself is never sent or stored.
+- **On the device:** the two hashes are stored in the app's private storage and excluded from backups and device transfers.
+- **In transit:** all connections use HTTPS with the certificates Android trusts. A self-signed certificate is only accepted if you enabled it and confirmed its fingerprint, and then only that exact certificate.
+
+## Known limitations
+
+- The salt is derived from your username and password, and the iteration counts are low. Both are needed for compatibility with the other Clipster clients. Whoever runs the server receives your login hash and could try to guess your password offline, which would also reveal the encryption key. **Use a strong, unique password**, or run your own server.
+- The server can see metadata: your username, when you share, the size and format (text or image) of clips, and the device name.
diff --git a/app/.gitignore b/app/.gitignore
deleted file mode 100644
index 796b96d..0000000
--- a/app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/app/build.gradle b/app/build.gradle
index 131a27d..8ded57c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,5 +1,23 @@
plugins {
- id 'com.android.application'
+ alias(libs.plugins.android.application)
+}
+
+// The git tag (vMAJOR.MINOR.PATCH) is the only place the version is maintained.
+// The release workflow passes it with -PappVersion=v1.2.3, local builds use the latest tag.
+def appVersion = providers.gradleProperty('appVersion').orElse(
+ providers.exec {
+ commandLine 'git', 'describe', '--tags', '--abbrev=0', '--match', 'v*'
+ ignoreExitValue = true
+ }.standardOutput.asText.map { it.trim() ?: 'v0.0.1' }
+).get()
+
+def versionMatch = appVersion =~ /^v?(\d+)\.(\d+)\.(\d+)$/
+if (!versionMatch.matches()) {
+ throw new GradleException("Version '$appVersion' must look like v1.2.3")
+}
+def (versionMajor, versionMinor, versionPatch) = (1..3).collect { versionMatch.group(it).toInteger() }
+if (versionMinor > 99 || versionPatch > 99) {
+ throw new GradleException("Minor and patch version must be below 100 to keep versionCode increasing")
}
android {
@@ -27,16 +45,19 @@ android {
}
defaultConfig {
+ // Also hardcoded in res/xml/shortcuts.xml
applicationId = "com.data_dive.com.clipster"
minSdk = 26
- targetSdk = 36
- versionCode = 9
- versionName = "0.6.0"
+ targetSdk = 37
+ // e.g. v0.7.2 -> 702. Must only ever increase, Android refuses to install downgrades.
+ versionCode = versionMajor * 10000 + versionMinor * 100 + versionPatch
+ versionName = "$versionMajor.$versionMinor.$versionPatch"
}
buildTypes {
release {
minifyEnabled = true
+ shrinkResources = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (keyPropsFile.exists()) {
@@ -51,12 +72,32 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
+ lint {
+ abortOnError = true
+ warningsAsErrors = true
+ // Only reported: Dependabot takes care of updates, new SDK versions need testing first
+ informational += ['GradleDependency', 'AndroidGradlePluginVersion', 'NewerVersionAvailable', 'OldTargetApi']
+ }
+
+ testOptions {
+ // android.util.Log etc. return defaults instead of throwing in JVM unit tests
+ unitTests.returnDefaultValues = true
+ }
+
}
dependencies {
- implementation 'androidx.appcompat:appcompat:1.8.0'
- implementation 'androidx.core:core:1.19.1'
- implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
- implementation 'com.macasaet.fernet:fernet-java8:1.5.0'
- implementation 'com.amdelamar:jhash:2.2.0'
+ implementation libs.androidx.appcompat
+ implementation libs.androidx.core
+ implementation libs.androidx.constraintlayout
+ implementation libs.material
+ implementation libs.okhttp
+ implementation libs.fernet
+ implementation libs.jhash
+
+ testImplementation libs.junit
+ // The Android SDK only ships stubs of org.json for unit tests
+ testImplementation libs.org.json
+ // Generates certificates for the TLS trust tests
+ testImplementation libs.okhttp.tls
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0e10f5b..f1b9cf4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
@@ -10,14 +12,16 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
+
+ android:theme="@style/Theme.Clipster">
+ android:excludeFromRecents="true"
+ android:exported="true"
+ android:theme="@style/Theme.Clipster.Translucent">
@@ -38,12 +44,16 @@
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
+
{
+ T run() throws Exception;
+ }
+
+ interface Callback {
+ /** Called on the main thread. Exactly one of result or error is non-null (result may be null on success). */
+ void done(T result, Exception error);
+ }
+
+ private Async() {}
+
+ static void run(Work work, Callback callback) {
+ EXECUTOR.execute(() -> {
+ T result = null;
+ Exception error = null;
+ try {
+ result = work.run();
+ } catch (Exception e) {
+ error = e;
+ } catch (OutOfMemoryError e) {
+ // Large images can exhaust the heap, report it instead of leaving the UI waiting
+ error = new RuntimeException(e);
+ }
+ final T r = result;
+ final Exception err = error;
+ MAIN.post(() -> callback.done(r, err));
+ });
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/Clip.java b/app/src/main/java/com/data_dive/com/clipster/Clip.java
new file mode 100644
index 0000000..f19a5e7
--- /dev/null
+++ b/app/src/main/java/com/data_dive/com/clipster/Clip.java
@@ -0,0 +1,87 @@
+package com.data_dive.com.clipster;
+
+import android.util.Log;
+import com.macasaet.fernet.Key;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.List;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A decrypted clip as returned by the server (see ClipSerializer in Clipster-Server)
+ */
+public final class Clip {
+
+ public static final String FORMAT_TXT = "txt";
+ public static final String FORMAT_IMG = "img";
+
+ private static final String TAG = "Clip";
+
+ /** Decrypted text, or base64 encoded PNG for images */
+ public final String text;
+
+ public final String format;
+ /** Name of the device that shared the clip, may be empty */
+ public final String device;
+ /** When the clip was shared, null if the server didn't send a valid timestamp */
+ public final Instant createdAt;
+
+ public Clip(String text, String format, String device, Instant createdAt) {
+ this.text = text;
+ this.format = format;
+ this.device = device;
+ this.createdAt = createdAt;
+ }
+
+ public boolean isImage() {
+ return FORMAT_IMG.equals(format);
+ }
+
+ /**
+ * Parse and decrypt the server response, which is an array of clips or a single clip.
+ * Clips that can't be decrypted are shown as text with errorText, so one bad clip doesn't hide the others.
+ */
+ public static List listFromJson(String body, Key key, String errorText) throws JSONException {
+ JSONArray array;
+ String trimmed = body.trim();
+ if (trimmed.startsWith("{")) {
+ array = new JSONArray().put(new JSONObject(trimmed));
+ } else {
+ array = new JSONArray(trimmed);
+ }
+ List clips = new ArrayList<>(array.length());
+ for (int i = 0; i < array.length(); i++) {
+ clips.add(fromJson(array.getJSONObject(i), key, errorText));
+ }
+ return clips;
+ }
+
+ static Clip fromJson(JSONObject json, Key key, String errorText) {
+ String format = json.optString("format", FORMAT_TXT);
+ String text;
+ try {
+ text = Crypto.decrypt(key, json.getString("text"));
+ } catch (JSONException | RuntimeException e) {
+ Log.e(TAG, "Could not decrypt clip: " + e.getClass().getSimpleName());
+ text = errorText;
+ format = FORMAT_TXT;
+ }
+ return new Clip(text, format, json.optString("device", ""), parseTimestamp(json.optString("created_at")));
+ }
+
+ static Instant parseTimestamp(String value) {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ // Django REST framework sends ISO 8601, e.g. 2026-09-28T19:55:18.431234Z or with +02:00
+ return OffsetDateTime.parse(value).toInstant();
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java b/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java
new file mode 100644
index 0000000..49f3f62
--- /dev/null
+++ b/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java
@@ -0,0 +1,139 @@
+package com.data_dive.com.clipster;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.text.format.DateUtils;
+import android.util.LruCache;
+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 java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Shows decrypted text and image clips with their device and time. Rows are recycled and images
+ * are decoded in the background as downsampled thumbnails, so scrolling stays smooth.
+ */
+public class ClipListAdapter extends BaseAdapter {
+
+ private static final int TYPE_TXT = 0;
+ private static final int TYPE_IMG = 1;
+
+ private static final class ViewHolder {
+ final TextView text;
+ final ImageView image;
+ final TextView meta;
+
+ ViewHolder(View row) {
+ text = row.findViewById(R.id.txt);
+ image = row.findViewById(R.id.img);
+ meta = row.findViewById(R.id.meta);
+ }
+ }
+
+ private final Context context;
+ private final LayoutInflater inflater;
+ private final List clips;
+ private final int thumbnailSize;
+ private final Set loading = new HashSet<>();
+ private final LruCache thumbnails =
+ new LruCache((int) (Runtime.getRuntime().maxMemory() / 8)) {
+ @Override
+ protected int sizeOf(Integer key, Bitmap value) {
+ return value.getByteCount();
+ }
+ };
+
+ public ClipListAdapter(Context context, List clips) {
+ this.context = context;
+ this.inflater = LayoutInflater.from(context);
+ this.clips = clips;
+ this.thumbnailSize = context.getResources().getDisplayMetrics().widthPixels;
+ }
+
+ @Override
+ public int getCount() {
+ return clips.size();
+ }
+
+ @Override
+ public Clip getItem(int position) {
+ return clips.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return getItem(position).isImage() ? TYPE_IMG : TYPE_TXT;
+ }
+
+ @Override
+ public View getView(int position, View row, ViewGroup parent) {
+ Clip clip = getItem(position);
+ if (row == null) {
+ row = inflater.inflate(clip.isImage() ? R.layout.list_single_img : R.layout.list_single_txt, parent, false);
+ row.setTag(new ViewHolder(row));
+ }
+ ViewHolder holder = (ViewHolder) row.getTag();
+ String meta = metaText(clip);
+ holder.meta.setText(meta);
+ holder.meta.setVisibility(meta.isEmpty() ? View.GONE : View.VISIBLE);
+
+ if (clip.isImage()) {
+ bindImage(holder.image, position, clip);
+ } else {
+ holder.text.setText(clip.text);
+ }
+ return row;
+ }
+
+ private String metaText(Clip clip) {
+ String time = "";
+ if (clip.createdAt != null) {
+ long created = clip.createdAt.toEpochMilli();
+ long now = System.currentTimeMillis();
+ time = now - created < DateUtils.MINUTE_IN_MILLIS
+ ? context.getString(R.string.clip_just_now)
+ : DateUtils.getRelativeTimeSpanString(
+ created, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE)
+ .toString();
+ }
+ if (clip.device.isEmpty() || time.isEmpty()) {
+ return clip.device + time;
+ }
+ return context.getString(R.string.clip_meta, clip.device, time);
+ }
+
+ private void bindImage(ImageView imageView, int position, Clip clip) {
+ imageView.setTag(position);
+ Bitmap cached = thumbnails.get(position);
+ imageView.setImageBitmap(cached);
+ if (cached != null || !loading.add(position)) {
+ return;
+ }
+ Async.run(() -> Utils.b64ToThumbnail(clip.text, thumbnailSize), (bitmap, error) -> {
+ loading.remove(position);
+ if (bitmap == null) {
+ return;
+ }
+ thumbnails.put(position, bitmap);
+ // The row may have been recycled for another clip in the meantime
+ if (Integer.valueOf(position).equals(imageView.getTag())) {
+ imageView.setImageBitmap(bitmap);
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/Clips.java b/app/src/main/java/com/data_dive/com/clipster/Clips.java
index 136b9d6..92a82ba 100644
--- a/app/src/main/java/com/data_dive/com/clipster/Clips.java
+++ b/app/src/main/java/com/data_dive/com/clipster/Clips.java
@@ -1,27 +1,23 @@
package com.data_dive.com.clipster;
-import org.json.JSONArray;
-public class Clips {
+import java.util.List;
- private static Clips instance;
+/**
+ * Holds the clips shown by ListClipsActivity. They can be too large for an Intent extra,
+ * so they are passed in memory and are lost when Android kills the process.
+ */
+public final class Clips {
- // Global variable
- private JSONArray clips;
+ private static List clips;
- // Restrict the constructor from being instantiated
- private Clips(){}
+ private Clips() {}
- public void setData(JSONArray clips){
- this.clips = clips;
- }
- public JSONArray getData(){
- return this.clips;
+ public static synchronized void set(List newClips) {
+ clips = newClips;
}
- public static synchronized Clips getInstance(){
- if(instance==null){
- instance = new Clips();
- }
- return instance;
+ /** Null if no clips were loaded in this process */
+ public static synchronized List get() {
+ return clips;
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/Credentials.java b/app/src/main/java/com/data_dive/com/clipster/Credentials.java
index 5a7cd1b..0de3201 100644
--- a/app/src/main/java/com/data_dive/com/clipster/Credentials.java
+++ b/app/src/main/java/com/data_dive/com/clipster/Credentials.java
@@ -1,65 +1,104 @@
package com.data_dive.com.clipster;
-import android.util.Base64;
-import android.util.Log;
import com.macasaet.fernet.Key;
-
-import java.nio.charset.StandardCharsets;
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
/**
- * Define credentials object to pass around functions and activities
- * Create PB2KDF hash Key for symmetric encryption
+ * Credentials and server settings. Holds the PBKDF2 derived hashes, the password itself is never stored.
*/
-
public final class Credentials {
- private static String logtag = "Credentials";
-
- public Key encryption_key;
public final String user;
- public final String pw;
- public final String login_pw_hash;
- public final String msg_pw_hash;
- public final String token_b64;
+ public final String loginHash;
+ public final String msgHash;
+ public final String authToken;
+ public final Key encryptionKey;
public final String server;
- public final boolean ignore_cert;
+ /** Whether the user may be asked to trust a certificate Android doesn't trust (own server) */
+ public final boolean allowSelfSigned;
+ /** Self-signed server certificate the user confirmed, null if none */
+ public final X509Certificate pinnedCertificate;
- public Credentials(String username, String password, String login_hash, String msg_hash, String server, boolean ignore_cert) {
- /**
- * Constructor when credentials already saved
- */
- this.user = username;
- this.pw = password;
+ private Credentials(
+ String user,
+ String loginHash,
+ String msgHash,
+ String server,
+ boolean allowSelfSigned,
+ X509Certificate pinnedCertificate) {
+ this.user = user;
+ this.loginHash = loginHash;
+ this.msgHash = msgHash;
+ this.server = server;
+ this.allowSelfSigned = allowSelfSigned;
+ this.pinnedCertificate = pinnedCertificate;
+ this.encryptionKey = new Key(msgHash);
+ this.authToken = Crypto.authToken(user, loginHash);
+ }
- if(!pw.isEmpty()) {
- this.login_pw_hash = Utils.stringToHash(user, pw, Utils.CRYPT_ITERS_LOGIN_HASH);
- this.msg_pw_hash = Utils.stringToHash(user, pw, Utils.CRYPT_ITERS_MSG_HASH);
- } else {
- this.login_pw_hash = login_hash;
- this.msg_pw_hash = msg_hash;
- }
+ /**
+ * Derive hashes from a password. Slow by design, don't call on the main thread.
+ */
+ public static Credentials fromPassword(String user, String password, String server, boolean allowSelfSigned) {
+ return new Credentials(
+ user,
+ Crypto.deriveKey(user, password, Crypto.ITERS_LOGIN_HASH),
+ Crypto.deriveKey(user, password, Crypto.ITERS_MSG_HASH),
+ server,
+ allowSelfSigned,
+ null);
+ }
- this.server = server;
- this.ignore_cert = ignore_cert;
+ /**
+ * @param pinnedCertificate base64 DER encoded certificate, empty or null if none
+ */
+ public static Credentials fromSaved(
+ String user,
+ String loginHash,
+ String msgHash,
+ String server,
+ boolean allowSelfSigned,
+ String pinnedCertificate) {
+ return new Credentials(user, loginHash, msgHash, server, allowSelfSigned, decodeCertificate(pinnedCertificate));
+ }
- this.encryption_key = new Key(this.msg_pw_hash);
- this.token_b64 = createAuthToken(this.user, this.login_pw_hash);
- Log.d(logtag, "Called Constructor with:\n" + this.user + " " + this.pw + " " + this.login_pw_hash + " "
- + this.msg_pw_hash + " " + this.server + " " + token_b64 + " " + this.ignore_cert);
+ public Credentials withPinnedCertificate(X509Certificate certificate) {
+ return new Credentials(user, loginHash, msgHash, server, allowSelfSigned, certificate);
}
- private String createAuthToken(String user, String pw) {
- /**
- * Create b64 encoded String Token for Basic Auth to use for API requests
- */
- String token = user + ":" + pw;
+ /** Base64 DER encoding of the pinned certificate for storage, empty if none */
+ public String encodedPinnedCertificate() {
+ if (pinnedCertificate == null) {
+ return "";
+ }
+ try {
+ return Base64.getEncoder().encodeToString(pinnedCertificate.getEncoded());
+ } catch (CertificateEncodingException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static X509Certificate decodeCertificate(String encoded) {
+ if (encoded == null || encoded.isEmpty()) {
+ return null;
+ }
try {
- byte[] token_byte = token.getBytes(StandardCharsets.UTF_8);
- token = Base64.encodeToString(token_byte, Base64.NO_WRAP);
- } catch (Exception UnsupportedEncodingException) {
- Log.e(logtag, "UnsupportedEncodingException");
- token = null;
+ byte[] der = Base64.getDecoder().decode(encoded);
+ return (X509Certificate)
+ CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(der));
+ } catch (CertificateException | IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid pinned certificate", e);
}
- return token;
+ }
+
+ @Override
+ public String toString() {
+ return "Credentials{user=" + user + ", server=" + server + ", allowSelfSigned=" + allowSelfSigned + ", pinned="
+ + (pinnedCertificate != null) + "}";
}
}
diff --git a/app/src/main/java/com/data_dive/com/clipster/Crypto.java b/app/src/main/java/com/data_dive/com/clipster/Crypto.java
new file mode 100644
index 0000000..52bff69
--- /dev/null
+++ b/app/src/main/java/com/data_dive/com/clipster/Crypto.java
@@ -0,0 +1,77 @@
+package com.data_dive.com.clipster;
+
+import com.amdelamar.jhash.Hash;
+import com.amdelamar.jhash.algorithms.Type;
+import com.macasaet.fernet.Key;
+import com.macasaet.fernet.StringValidator;
+import com.macasaet.fernet.Token;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.temporal.TemporalAmount;
+import java.util.Base64;
+
+/**
+ * Key derivation and Fernet encryption. Free of Android dependencies so it can be unit tested.
+ * All parameters must stay in sync with Clipster-Desktop (clipster/crypt.go), otherwise clients
+ * can no longer authenticate or decrypt each other's clips.
+ */
+public final class Crypto {
+
+ public static final int ITERS_LOGIN_HASH = 20000;
+ public static final int ITERS_MSG_HASH = 10000;
+ private static final int HASH_LENGTH = 32;
+ private static final Duration TOKEN_TTL = Duration.ofDays(3650);
+
+ private Crypto() {}
+
+ /**
+ * PBKDF2-SHA256 hash of the password, returned as url-safe base64
+ */
+ public static String deriveKey(String user, String password, int iterations) {
+ byte[] salt = ("clipster_" + user + "_" + password).getBytes(StandardCharsets.UTF_8);
+ String h = Hash.password(password.toCharArray())
+ .algorithm(Type.PBKDF2_SHA256)
+ .salt(salt)
+ .hashLength(HASH_LENGTH)
+ .factor(iterations)
+ .create();
+ // jhash returns 7 colon separated fields, the last one is the standard base64 hash
+ String hash = h.split(":")[6];
+ return hash.replace('/', '_').replace('+', '-');
+ }
+
+ /**
+ * Base64 encoded "user:login_hash" for HTTP Basic Auth
+ */
+ public static String authToken(String user, String loginHash) {
+ String token = user + ":" + loginHash;
+ return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static String encrypt(Key key, String text) {
+ return Token.generate(key, text).serialise();
+ }
+
+ /**
+ * @throws RuntimeException if the token is malformed, expired or was encrypted with another key
+ */
+ public static String decrypt(Key key, String token) {
+ return decrypt(key, token, Clock.systemUTC());
+ }
+
+ static String decrypt(Key key, String token, Clock clock) {
+ StringValidator validator = new StringValidator() {
+ @Override
+ public TemporalAmount getTimeToLive() {
+ return TOKEN_TTL;
+ }
+
+ @Override
+ public Clock getClock() {
+ return clock;
+ }
+ };
+ return Token.fromString(token).validateAndDecrypt(key, validator);
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/CustomList.java b/app/src/main/java/com/data_dive/com/clipster/CustomList.java
deleted file mode 100644
index 3550b9f..0000000
--- a/app/src/main/java/com/data_dive/com/clipster/CustomList.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.data_dive.com.clipster;
-
-import android.app.Activity;
-import android.graphics.Bitmap;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-
-public class CustomList extends ArrayAdapter{
-
- public final String logtag = this.getClass().getSimpleName();
- private final Activity context;
- private final JSONArray clips;
- private final String[] entry_placeholders;
-
- public CustomList(Activity context, JSONArray clips, String[] entry_placeholders) {
-
- super(context, R.layout.list_single_txt, entry_placeholders);
- Log.d(logtag, "CustomList constructor");
- this.context = context;
- this.entry_placeholders = entry_placeholders;
- this.clips = clips;
- }
-
- @Override
- public View getView(int position, View view, ViewGroup parent) {
- Log.d(logtag, "getView");
- String text_decrypted = "";
- String format = "txt";
- Bitmap image;
-
- LayoutInflater inflater = context.getLayoutInflater();
- View rowView = null;
-
- try {
- text_decrypted = clips.getJSONObject(position).getString("text_decrypted");
- format = clips.getJSONObject(position).getString("format");
- } catch(JSONException e) {
- Log.e(logtag, "getView:" + e);
- }
-
- if (format.equals("img")) {
- rowView = inflater.inflate(R.layout.list_single_img, null, true);
- image = Utils.B64StringToImage(text_decrypted);
- ImageView imageView = (ImageView) rowView.findViewById(R.id.img);
- imageView.setImageBitmap(image);
- } else {
- rowView = inflater.inflate(R.layout.list_single_txt, null, true);
- TextView txtTitle = (TextView) rowView.findViewById(R.id.txt);
- txtTitle.setText(text_decrypted);
- }
-
- return rowView;
- }
- }
\ No newline at end of file
diff --git a/app/src/main/java/com/data_dive/com/clipster/DebouncedOnClickListener.java b/app/src/main/java/com/data_dive/com/clipster/DebouncedOnClickListener.java
deleted file mode 100644
index 9f367ed..0000000
--- a/app/src/main/java/com/data_dive/com/clipster/DebouncedOnClickListener.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.data_dive.com.clipster;
-
-import android.content.Context;
-import android.os.SystemClock;
-import android.util.Log;
-import android.view.View;
-import android.widget.Toast;
-
-import java.util.Map;
-import java.util.WeakHashMap;
-
-/**
- * A Debounced OnClickListener
- * Rejects clicks that are too close together in time.
- * This class is safe to use as an OnClickListener for multiple views, and will debounce each one separately.
- * Source: https://stackoverflow.com/a/16534470/5251061
- */
-
-public abstract class DebouncedOnClickListener implements View.OnClickListener {
-
- private final long minimumIntervalMillis;
- private Map lastClickMap;
- private Context context;
-
- public abstract void onDebouncedClick(View v);
-
- public DebouncedOnClickListener(long minimumIntervalMillis, Context mContext) {
- this.context = mContext;
- this.minimumIntervalMillis = minimumIntervalMillis;
- this.lastClickMap = new WeakHashMap<>();
- }
-
- @Override
- public void onClick(View clickedView) {
- Long previousClickTimestamp = lastClickMap.get(clickedView);
- long currentTimestamp = SystemClock.uptimeMillis();
- lastClickMap.put(clickedView, currentTimestamp);
- if(previousClickTimestamp == null
- || Math.abs(currentTimestamp - previousClickTimestamp) > minimumIntervalMillis) {
- onDebouncedClick(clickedView);
- } else {
- Log.w("DebouncedClick", "Clicking too fast there!");
- Toast.makeText(context, R.string.msg_warn_clicking_too_fast,
- Toast.LENGTH_LONG).show();
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/data_dive/com/clipster/ListClipsActivity.java b/app/src/main/java/com/data_dive/com/clipster/ListClipsActivity.java
index 863d07c..30e668a 100644
--- a/app/src/main/java/com/data_dive/com/clipster/ListClipsActivity.java
+++ b/app/src/main/java/com/data_dive/com/clipster/ListClipsActivity.java
@@ -1,186 +1,152 @@
package com.data_dive.com.clipster;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.widget.PopupMenu;
-import androidx.core.app.ActivityCompat;
-
import android.Manifest;
+import android.content.ClipData;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.graphics.Bitmap;
-import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
-import android.view.MenuItem;
import android.view.View;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
import android.widget.ListView;
-import android.widget.TextView;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-
+import android.widget.Toast;
+import androidx.activity.EdgeToEdge;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.PopupMenu;
+import androidx.core.app.ActivityCompat;
+import com.google.android.material.appbar.MaterialToolbar;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
public class ListClipsActivity extends AppCompatActivity {
- public final String logtag = this.getClass().getSimpleName();
- private static String clip_text;
- private static String clip_format;
- private static final int BUTTON_DELAY = 2000;
+ private static final String TAG = "ListClipsActivity";
private static final int WRITE_EXTERNAL_STORAGE_REQUEST = 123;
- Bitmap image;
- TextView copy_to_clipboard, back;
- ListView list;
+ // Image waiting for the storage permission (only needed up to Android 9)
+ private String pendingImage;
@Override
protected void onCreate(Bundle savedInstanceState) {
+ EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_list_clips);
-
- // Buttons
- back = findViewById(R.id.Back);
-
- // Initialize ListView for showing all Clips
- list = findViewById(R.id.ListOfClipsView);
- Clips myClips = Clips.getInstance();
- JSONArray clips = myClips.getData();
- if(clips != null && clips.length() > 0) {
- // hackiest hack of all shitty hacks
- int num_entries = clips.length();
- String[] entry_placeholders = new String[num_entries];
- CustomList cust_adapter = new CustomList(ListClipsActivity.this, clips, entry_placeholders);
- list.setAdapter(cust_adapter);
- } else {
- // No Clips yet
- ArrayAdapter adapter = new ArrayAdapter(this, R.layout.list_clips_items,
- R.id.clip_items_textview, new String[]{getString(R.string.empty_clip_list)});
- list.setAdapter(adapter);
+ List loaded = Clips.get();
+ if (loaded == null) {
+ // Clips only live in memory, e.g. lost when Android killed the app in the background
+ finish();
+ return;
}
+ // The server sends the oldest clip first
+ List clips = new ArrayList<>(loaded);
+ Collections.reverse(clips);
- back.setOnClickListener(btnListener);
- back.setTag("back");
+ setContentView(R.layout.activity_list_clips);
- JSONArray finalClips = clips;
- list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
- @Override
- public void onItemClick(AdapterView> parent, final View view,
- int position, long id) {
- if (finalClips == null) {
- // Placeholder entry "no clips yet"
- return;
- }
- try {
- clip_text = finalClips.getJSONObject(position).getString("text_decrypted");
- clip_format = finalClips.getJSONObject(position).getString("format");
- } catch (JSONException e) {
- Log.e(logtag, "Error: " + e);
- return;
- }
- Log.d(logtag, "Got Item: " + clip_text);
- openPopupMenu(view, clip_text, clip_format);
- }
+ MaterialToolbar toolbar = findViewById(R.id.toolbar);
+ toolbar.setNavigationOnClickListener(v -> finish());
- });
+ ListView list = findViewById(R.id.clip_list);
+ list.setEmptyView(findViewById(R.id.empty));
+ ClipListAdapter adapter = new ClipListAdapter(this, clips);
+ list.setAdapter(adapter);
+ list.setOnItemClickListener((parent, view, position, id) -> openPopupMenu(view, adapter.getItem(position)));
}
- private void openPopupMenu(View view, String clip_text, String clip_format) {
- // Initializing the popup menu and giving the reference as current context
+ private void openPopupMenu(View view, Clip clip) {
PopupMenu popupMenu = new PopupMenu(this, view);
-
- if (clip_format.equals("img")) {
- popupMenu.getMenuInflater().inflate(R.menu.popup_menu_image, popupMenu.getMenu());
- } else {
- popupMenu.getMenuInflater().inflate(R.menu.popup_menu_text, popupMenu.getMenu());
- }
-
- popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
- @Override
- public boolean onMenuItemClick(MenuItem menuItem) {
- Log.d(logtag, "Got Menu Item Text: " + menuItem.getTitle());
- Log.d(logtag, "Got Menu Item ID: " + menuItem.getItemId());
-
- if (menuItem.getItemId() == R.id.copy_to_clipboard) {
- Utils.setClipboard(view.getContext(), clip_text, clip_format);
- } else if (menuItem.getItemId() == R.id.share) {
- openShareMenu(clip_text, clip_format);
- } else if (menuItem.getItemId() == R.id.save_to_file) {
- image = Utils.B64StringToImage(clip_text);
- getPermissionAndSaveBitmapToGallery(image);
- }
-
- return true;
+ popupMenu
+ .getMenuInflater()
+ .inflate(clip.isImage() ? R.menu.popup_menu_image : R.menu.popup_menu_text, popupMenu.getMenu());
+
+ popupMenu.setOnMenuItemClickListener(menuItem -> {
+ int id = menuItem.getItemId();
+ if (id == R.id.copy_to_clipboard) {
+ Utils.setClipboard(
+ this,
+ ClipData.newPlainText("Clipster", clip.text),
+ Utils.clipPreview(this, clip.text, clip.format));
+ } else if (id == R.id.share) {
+ openShareMenu(clip);
+ } else if (id == R.id.save_to_file) {
+ saveToGalleryWithPermission(clip.text);
}
+ return true;
});
popupMenu.show();
}
- private void getPermissionAndSaveBitmapToGallery(Bitmap image) {
+ private void saveToGalleryWithPermission(String imageB64) {
// MediaStore needs no storage permission from Android 10 on
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- Utils.SaveBitmapToGallery(this, image, "Clipster image", "Image shared via Clipster");
- return;
- }
- int check = ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE);
- if (check == PackageManager.PERMISSION_GRANTED) {
- Log.d(logtag, "Has permissions for WRITE_EXTERNAL_STORAGE");
- Utils.SaveBitmapToGallery(this, image, "Clipster image", "Image shared via Clipster");
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ || ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ == PackageManager.PERMISSION_GRANTED) {
+ saveToGallery(imageB64);
} else {
- Log.d(logtag, "No Permission for WRITE_EXTERNAL_STORAGE. Asking for it");
- requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, WRITE_EXTERNAL_STORAGE_REQUEST);
+ pendingImage = imageB64;
+ ActivityCompat.requestPermissions(
+ this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, WRITE_EXTERNAL_STORAGE_REQUEST);
}
+ }
+ private void saveToGallery(String imageB64) {
+ Async.run(() -> Utils.saveBitmapToGallery(this, Utils.b64ToBitmap(imageB64)), (saved, error) -> {
+ boolean ok = error == null && Boolean.TRUE.equals(saved);
+ if (!ok) {
+ Log.e(TAG, "Could not save image: " + error);
+ }
+ Toast.makeText(this, ok ? R.string.msg_image_saved : R.string.error_save_image, Toast.LENGTH_LONG)
+ .show();
+ });
}
@Override
- public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- switch (requestCode) {
- case WRITE_EXTERNAL_STORAGE_REQUEST: {
- if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- Log.d(logtag, "Permission granted!");
- Utils.SaveBitmapToGallery(this, image, "Clipster image", "Image shared via Clipster");
- } else {
- Log.d(logtag, "Permission not granted");
- }
+ if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST && pendingImage != null) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ saveToGallery(pendingImage);
+ } else {
+ Toast.makeText(this, R.string.error_save_image, Toast.LENGTH_LONG)
+ .show();
}
+ pendingImage = null;
}
}
-
- private void openShareMenu(String clip_text, String clip_format) {
- // openShareMenu and deal with txt vs image sharing
- final Intent intent = new Intent(Intent.ACTION_SEND);
- if (clip_format.equals("txt")) {
+ private void openShareMenu(Clip clip) {
+ if (!clip.isImage()) {
+ Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
- intent.putExtra(Intent.EXTRA_TEXT, clip_text);
- } else if (clip_format.equals("img")) {
+ intent.putExtra(Intent.EXTRA_TEXT, clip.text);
+ startChooser(intent);
+ return;
+ }
+ Async.run(() -> Utils.bitmapToTempFileUri(this, Utils.b64ToBitmap(clip.text)), (imageUri, error) -> {
+ if (imageUri == null) {
+ Toast.makeText(this, R.string.error_open_image, Toast.LENGTH_LONG)
+ .show();
+ return;
+ }
+ Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("image/png");
- Bitmap image = Utils.B64StringToImage(clip_text);
- Uri imageUri = Utils.BitmapToTempFileAsUri(this, image);
intent.putExtra(Intent.EXTRA_STREAM, imageUri);
+ intent.setClipData(ClipData.newRawUri(null, imageUri));
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startChooser(intent);
+ });
+ }
+
+ private void startChooser(Intent intent) {
+ if (isFinishing() || isDestroyed()) {
+ return;
}
try {
- startActivity(Intent.createChooser(intent, "Select an action"));
+ startActivity(Intent.createChooser(intent, getString(R.string.share_chooser_title)));
} catch (android.content.ActivityNotFoundException e) {
- Log.e(logtag, "Error: " + e);
+ Log.e(TAG, "No app to share with: " + e);
}
}
-
- private View.OnClickListener btnListener = new DebouncedOnClickListener(BUTTON_DELAY, this) {
- public void onDebouncedClick(View v) {
- // Delay excessive clicks
- String action_tag = v.getTag().toString();
- Log.d(logtag, "Clicked Button: " + action_tag);
- if(action_tag.equals("back")) {
- finish();
- } else if (action_tag.equals("copy_to_clipboard")) {
- // Utils.setClipboard(v.getContext(), clip_text);
- }
- }
- };
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/LocalNetwork.java b/app/src/main/java/com/data_dive/com/clipster/LocalNetwork.java
new file mode 100644
index 0000000..a17f2e6
--- /dev/null
+++ b/app/src/main/java/com/data_dive/com/clipster/LocalNetwork.java
@@ -0,0 +1,64 @@
+package com.data_dive.com.clipster;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.UnknownHostException;
+import java.util.Locale;
+
+/**
+ * From Android 17 on, apps need the ACCESS_LOCAL_NETWORK runtime permission to reach
+ * servers on the local network, e.g. a self-hosted Clipster server at home.
+ */
+final class LocalNetwork {
+
+ static final String PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK";
+ private static final int ANDROID_17 = 37;
+
+ private LocalNetwork() {}
+
+ /** True if the permission is enforced on this device and not granted yet */
+ static boolean isPermissionMissing(Context context) {
+ return Build.VERSION.SDK_INT >= ANDROID_17
+ && context.checkSelfPermission(PERMISSION) != PackageManager.PERMISSION_GRANTED;
+ }
+
+ /**
+ * Whether the server resolves to a local network address. Does a DNS lookup, so call it in the background.
+ */
+ static boolean isLocalServer(String serverUri) {
+ String host;
+ try {
+ host = URI.create(serverUri).getHost();
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ if (host == null) {
+ return false;
+ }
+ if (host.toLowerCase(Locale.ROOT).endsWith(".local")) {
+ return true;
+ }
+ try {
+ for (InetAddress address : InetAddress.getAllByName(host)) {
+ if (isLocalAddress(address)) {
+ return true;
+ }
+ }
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ return false;
+ }
+
+ static boolean isLocalAddress(InetAddress address) {
+ if (address.isSiteLocalAddress() || address.isLinkLocalAddress()) {
+ return true;
+ }
+ // IPv6 unique local addresses fc00::/7
+ return address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc;
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/MainActivity.java b/app/src/main/java/com/data_dive/com/clipster/MainActivity.java
index d18ead4..fcbe8b3 100644
--- a/app/src/main/java/com/data_dive/com/clipster/MainActivity.java
+++ b/app/src/main/java/com/data_dive/com/clipster/MainActivity.java
@@ -1,149 +1,198 @@
package com.data_dive.com.clipster;
-import androidx.appcompat.app.AppCompatActivity;
-
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
-import android.widget.CheckBox;
-import android.widget.EditText;
-import android.widget.TextView;
import android.widget.Toast;
+import androidx.activity.EdgeToEdge;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.checkbox.MaterialCheckBox;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+import com.google.android.material.textfield.TextInputEditText;
+import com.google.android.material.textfield.TextInputLayout;
/**
- * Launcher Activity which checks if we need to setup first or can go straight to ready mode
+ * Launcher Activity: login or register, or go straight to ReadyActivity if we are logged in
*/
-
-public class MainActivity extends AppCompatActivity {
-
- private final static String logtag = "MainActivity";
- private final static int BUTTON_DELAY = 2000;
-
- private String SERVER_URI;
- EditText password, user, server;
- CheckBox ignore_cert;
- TextView register, login;
- String usr, pw, srv;
-
- @Override
- protected void onResume() {
- super.onResume();
- Log.d(logtag, "on Resume: Check for creds");
- checkForCreds();
+public class MainActivity extends AppCompatActivity implements NetClient.Listener {
+
+ private static final String TAG = "MainActivity";
+ // Launched by the app shortcut, see res/xml/shortcuts.xml
+ public static final String ACTION_GET_LAST_CLIP = "com.data_dive.com.clipster.action.GET_LAST_CLIP";
+ private static final int LOCAL_NETWORK_REQUEST = 1;
+
+ private TextInputLayout userLayout, passwordLayout, serverLayout;
+ private TextInputEditText user, password, server;
+ private MaterialCheckBox allowSelfSigned;
+ private MaterialButton login, register;
+ private LinearProgressIndicator progress;
+ private int runningTasks = 0;
+
+ // Waiting for the local network permission
+ private Credentials pendingCredentials;
+ private boolean pendingIsLogin;
+
+ private static final class Setup {
+ final Credentials credentials;
+ final boolean needsLocalNetwork;
+
+ Setup(Credentials credentials, boolean needsLocalNetwork) {
+ this.credentials = credentials;
+ this.needsLocalNetwork = needsLocalNetwork;
+ }
}
@Override
protected void onCreate(Bundle savedInstanceState) {
- Log.d(logtag, "Oncreate");
+ EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
-
- SERVER_URI = getResources().getString(R.string.default_host);
-
setContentView(R.layout.activity_main);
+ userLayout = findViewById(R.id.user_layout);
+ passwordLayout = findViewById(R.id.pw_layout);
+ serverLayout = findViewById(R.id.server_layout);
user = findViewById(R.id.user);
password = findViewById(R.id.pw);
server = findViewById(R.id.server);
login = findViewById(R.id.login);
register = findViewById(R.id.register);
- ignore_cert = findViewById(R.id.ignore_cert);
+ allowSelfSigned = findViewById(R.id.allow_self_signed);
+ progress = findViewById(R.id.progress);
- login.setOnClickListener(btnListener);
- login.setTag("login");
- register.setOnClickListener(btnListener);
- register.setTag("register");
- }
+ login.setOnClickListener(v -> startSetup(true));
+ register.setOnClickListener(v -> startSetup(false));
- private final View.OnClickListener btnListener = new DebouncedOnClickListener(BUTTON_DELAY, this) {
- public void onDebouncedClick(View v) {
- // Handle Clicks but debounced
- String action_tag = v.getTag().toString();
- Log.d(logtag, "Clicked Button: " + action_tag);
- prepareSetupRequest(action_tag);
+ if (Intent.ACTION_EDIT.equals(getIntent().getAction()) && savedInstanceState == null) {
+ showSavedCreds();
}
- };
+ }
- private void displaySavedCredsAsDefaults() {
- // Show saved credentials as default entries
- if(Utils.areCredsSaved(this)) {
- Log.d(logtag, "Creds saved, setting as defaults");
+ @Override
+ protected void onResume() {
+ super.onResume();
+ skipLoginIfPossible();
+ }
+
+ private void showSavedCreds() {
+ // The password is never saved
+ if (Utils.areCredsSaved(this)) {
Credentials creds = Utils.getCreds(this);
user.setText(creds.user);
- password.setText(creds.pw);
server.setText(creds.server);
- } else {
- Log.d(logtag, "Creds not saved. Not displaying defaults");
+ allowSelfSigned.setChecked(creds.allowSelfSigned);
}
}
- private void checkForCreds() {
- // Get intent and action -> Edit Creds from ReadyActivity calls this
- Intent intent = getIntent();
- String action = intent.getAction();
+ private void skipLoginIfPossible() {
+ // Edit Credentials in ReadyActivity: stay here and allow the user to edit them
+ String action = getIntent().getAction();
if (Intent.ACTION_EDIT.equals(action)) {
- // Allow user to edit credentials
- displaySavedCredsAsDefaults();
- Log.d(logtag, "Received ACTION_EDIT Intent. Allow to edit credentials.");
- } else {
- // if Creds are saved skip to ReadyActivity
- Log.d(logtag, "Skip to check for Creds");
- if (!Utils.areCredsSaved(this)) {
- Log.d(logtag, "Creds are not saved yet. Ask for them.");
- } else {
- Log.d(logtag, "Creds available. Switch to Ready Activity.");
- Intent i = new Intent(this, ReadyActivity.class);
- startActivity(i);
- finish();
+ return;
+ }
+ if (Utils.areCredsSaved(this)) {
+ Intent intent = new Intent(this, ReadyActivity.class);
+ if (ACTION_GET_LAST_CLIP.equals(action)) {
+ intent.setAction(ACTION_GET_LAST_CLIP);
}
+ startActivity(intent);
+ finish();
+ } else if (ACTION_GET_LAST_CLIP.equals(action)) {
+ Toast.makeText(this, R.string.msg_login_first, Toast.LENGTH_LONG).show();
+ // Only show the hint once, not on every resume
+ getIntent().setAction(Intent.ACTION_MAIN);
}
}
- private void prepareSetupRequest(String action) {
- // Start Setup process: register or login
- usr = user.getText().toString();
- pw = password.getText().toString();
- srv = server.getText().toString();
- boolean ignore = ignore_cert.isChecked();
+ private void startSetup(boolean isLogin) {
+ String usr = text(user);
+ String pw = text(password);
+ String srv = text(server).trim();
+ boolean selfSigned = allowSelfSigned.isChecked();
+ srv = Utils.formatServerUri(srv.isEmpty() ? getString(R.string.default_host) : srv);
- if (srv.isEmpty()) {
- srv = SERVER_URI;
+ userLayout.setError(usr.isEmpty() ? getString(R.string.error_username_required) : null);
+ if (pw.isEmpty()) {
+ passwordLayout.setError(getString(R.string.error_password_required));
+ } else if (pw.length() < Utils.MIN_PW_LENGTH) {
+ passwordLayout.setError(getResources()
+ .getQuantityString(R.plurals.error_password_too_short, Utils.MIN_PW_LENGTH, Utils.MIN_PW_LENGTH));
+ } else {
+ passwordLayout.setError(null);
+ }
+ serverLayout.setError(Utils.isValidServerUri(srv) ? null : getString(R.string.error_server_invalid));
+ if (userLayout.getError() != null || passwordLayout.getError() != null || serverLayout.getError() != null) {
+ return;
}
- srv = Utils.formatURIProtocol(srv);
- if (usr.isEmpty() || pw.isEmpty()) {
- Toast.makeText(this, getString(R.string.app_name) +
- " - Please enter an username and password", Toast.LENGTH_LONG).show();
- } else if (pw.length() < Utils.MIN_PW_LENGTH) {
- Toast.makeText(this, getString(R.string.app_name) +
- " - Your password must be at least " + Utils.MIN_PW_LENGTH.toString() + " characters long",
- Toast.LENGTH_LONG).show();
- } else if (!Utils.validateServerURI(srv)) {
- Log.e(logtag, "server uri INVALID: " + srv);
- Toast.makeText(this, getString(R.string.app_name) +
- " - Invalid server address.\nFormat should be: https://clipster.cc:9999",
- Toast.LENGTH_LONG).show();
+ final String serverUri = srv;
+ onRequestStarted();
+ // Key derivation is deliberately slow and the local network check needs DNS
+ Async.run(
+ () -> new Setup(
+ Credentials.fromPassword(usr, pw, serverUri, selfSigned),
+ LocalNetwork.isPermissionMissing(this) && LocalNetwork.isLocalServer(serverUri)),
+ (setup, error) -> {
+ onRequestFinished();
+ if (error != null) {
+ Log.e(
+ TAG,
+ "Could not create credentials: "
+ + error.getClass().getSimpleName());
+ Toast.makeText(this, R.string.error_key_derivation, Toast.LENGTH_LONG)
+ .show();
+ } else if (setup.needsLocalNetwork) {
+ pendingCredentials = setup.credentials;
+ pendingIsLogin = isLogin;
+ requestPermissions(new String[] {LocalNetwork.PERMISSION}, LOCAL_NETWORK_REQUEST);
+ } else {
+ sendSetupRequest(setup.credentials, isLogin);
+ }
+ });
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == LOCAL_NETWORK_REQUEST && pendingCredentials != null) {
+ // If denied, the request fails and explains that the permission is needed
+ sendSetupRequest(pendingCredentials, pendingIsLogin);
+ pendingCredentials = null;
+ }
+ }
+
+ private void sendSetupRequest(Credentials credentials, boolean isLogin) {
+ NetClient client = new NetClient(this, credentials);
+ if (isLogin) {
+ client.login();
} else {
- Log.d(logtag, "server uri VALID: " + srv);
- Log.d(logtag, "Disable ssl certificate check: " + ignore);
- Credentials creds;
- try {
- creds = new Credentials(usr, pw, "", "", srv, ignore);
- } catch (RuntimeException e) {
- Log.e(logtag, "Could not create credentials: " + e);
- Toast.makeText(this, getString(R.string.app_name) +
- " - Could not create encryption key from password", Toast.LENGTH_LONG).show();
- return;
- }
- NetClient client = new NetClient(this, creds);
-
- if (action.equals("login")) {
- Log.d(logtag, "Calling login function");
- client.Login();
- } else if (action.equals("register")) {
- Log.d(logtag, "Calling register function");
- client.Register();
- }
+ client.register();
}
}
+
+ private static String text(TextInputEditText field) {
+ return field.getText() != null ? field.getText().toString() : "";
+ }
+
+ @Override
+ public void onRequestStarted() {
+ runningTasks++;
+ updateLoading();
+ }
+
+ @Override
+ public void onRequestFinished() {
+ runningTasks = Math.max(0, runningTasks - 1);
+ updateLoading();
+ }
+
+ private void updateLoading() {
+ boolean loading = runningTasks > 0;
+ progress.setVisibility(loading ? View.VISIBLE : View.INVISIBLE);
+ login.setEnabled(!loading);
+ register.setEnabled(!loading);
+ }
}
diff --git a/app/src/main/java/com/data_dive/com/clipster/NetClient.java b/app/src/main/java/com/data_dive/com/clipster/NetClient.java
index 7583a8e..4fe036e 100644
--- a/app/src/main/java/com/data_dive/com/clipster/NetClient.java
+++ b/app/src/main/java/com/data_dive/com/clipster/NetClient.java
@@ -1,459 +1,419 @@
package com.data_dive.com.clipster;
-import android.annotation.SuppressLint;
import android.app.Activity;
+import android.content.ClipData;
+import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
-import android.os.AsyncTask;
import android.provider.Settings;
import android.util.Log;
import android.widget.Toast;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import java.io.IOException;
+import java.net.URI;
import java.security.GeneralSecurityException;
-import java.util.ArrayList;
-
-import javax.net.ssl.HostnameVerifier;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.TrustManager;
-import javax.net.ssl.X509TrustManager;
-import java.security.cert.X509Certificate;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import org.json.JSONException;
+import org.json.JSONObject;
/**
- * Singleton Class for Dealing with Client requests
- * Async subclass for https requests
- * I guess this is really ugly: how would you do it properly?
- * TODO: Could clean up returns of request by using Object instead of Array
+ * Sends requests to the Clipster server. Network, encryption and decryption run in the background,
+ * results are handled on the main thread. If the calling context implements {@link Listener}
+ * it is notified when a request starts and finishes, e.g. to show a progress indicator.
*/
-
public class NetClient {
- private final static String logtag = "NetClient";
- private static final int TIMEOUT_CONN = 8000;
- private final static String URI_REGISTER = "/register/";
- private final static String URI_VERIFY = "/verify-user/";
- private final static String URI_CLIP = "/copy-paste/";
-
- private static Context mContext;
- private static Credentials credentials;
- private static String SERVER_URI = "";
- private static String clip_clear = "";
- private static String device_name;
-
- protected NetClient(Context context) {
- // Default constructor - we already have saved working credentials
- Log.d(logtag, "Default constructor. Getting creds.");
- credentials = Utils.getCreds(context);
- SERVER_URI = credentials.server;
- device_name = getDeviceName(context);
-
- Log.d(logtag, "Default constructor.\nSERVER: " + SERVER_URI + "\nDevice Name: " + device_name);
- if(credentials.ignore_cert) {
- disableSSLCertChecks();
- } else {
- enableSSLCertChecks();
- }
- mContext = context;
+ public interface Listener {
+ void onRequestStarted();
+
+ void onRequestFinished();
}
- protected NetClient(Context context, Credentials creds) {
- // Constructor before we have saved credentials
- credentials = creds;
- SERVER_URI = credentials.server;
- device_name = getDeviceName(context);
+ private enum RequestType {
+ LOGIN,
+ REGISTER,
+ GET_LAST_CLIP,
+ GET_ALL_CLIPS,
+ SET_CLIP
+ }
- if(credentials.ignore_cert) {
- disableSSLCertChecks();
- } else {
- enableSSLCertChecks();
+ /** Everything needed to send a request, so it can be repeated after trusting a certificate */
+ private static final class ApiRequest {
+ final RequestType type;
+ final String path;
+ final Async.Work payload;
+ final String preview;
+
+ ApiRequest(RequestType type, String path, Async.Work payload, String preview) {
+ this.type = type;
+ this.path = path;
+ this.payload = payload;
+ this.preview = preview;
}
- mContext = context;
}
- private String getDeviceName(Context context) {
- /**
- * Try to get the user defined device name
- * Unfortunately there is no definite way: we try the most common
- */
- String name;
- try {
- name = Settings.Secure.getString(context.getContentResolver(), "bluetooth_name");
- if(name == null) {
- name = Settings.System.getString(context.getContentResolver(), "bluetooth_name");
- }
- if(name == null) {
- name = Settings.Global.getString(context.getContentResolver(), Settings.Global.DEVICE_NAME);
- }
- if (name == null) {
- name = "android";
- }
- } catch(Exception e) {
- Log.e(logtag,"Couldn't get device name: " + e.toString());
- name = "android";
+ private static final class Result {
+ int code;
+ String body = "";
+ List clips = Collections.emptyList();
+ // Prepared in the background for GET_LAST_CLIP, as images need to be decoded and stored
+ ClipData clipData;
+ String clipPreview;
+ // Set when the server certificate isn't trusted, the user may choose to trust it
+ X509Certificate untrustedCertificate;
+ boolean localNetworkBlocked;
+
+ boolean isOk() {
+ return code == 200 || code == 201;
}
- return name;
}
- protected void Login() {
- ClientRequest req = new ClientRequest(mContext);
- String request_uri = SERVER_URI + URI_VERIFY;
- Log.d(logtag," Login function. Token: " + credentials.token_b64);
- req.execute(request_uri, credentials.token_b64, "login", "");
+ private static final String TAG = "NetClient";
+ private static final String PATH_REGISTER = "/register/";
+ private static final String PATH_VERIFY = "/verify-user/";
+ private static final String PATH_CLIP = "/copy-paste/";
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+ // Shared, so all clients reuse one connection pool and thread pool
+ private static final OkHttpClient BASE_CLIENT = new OkHttpClient.Builder()
+ .connectTimeout(8, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build();
+
+ private final Context context;
+ private final Context appContext;
+ private final Credentials credentials;
+ private final String deviceName;
+ private final PinningTrustManager trustManager;
+ private final OkHttpClient client;
+
+ public NetClient(Context context) {
+ // We already have saved working credentials
+ this(context, Utils.getCreds(context));
}
- protected void Register() {
- // Register new account on server
- ClientRequest req = new ClientRequest(mContext);
- String request_uri = SERVER_URI + URI_REGISTER;
- String payload = "";
- JSONObject jsonPayload = new JSONObject();
+ public NetClient(Context context, Credentials credentials) {
+ this.context = context;
+ this.appContext = context.getApplicationContext();
+ this.credentials = credentials;
+ this.deviceName = getDeviceName(context);
try {
- jsonPayload.put("username", credentials.user);
- jsonPayload.put("password", credentials.login_pw_hash);
- payload = jsonPayload.toString();
- Log.d(logtag, "Parsed text to json: " + payload);
- } catch(JSONException e) {
- Log.e(logtag, "Could not parse text to json: " + payload);
+ trustManager = new PinningTrustManager(credentials.pinnedCertificate);
+ SSLContext ssl = SSLContext.getInstance("TLS");
+ ssl.init(null, new TrustManager[] {trustManager}, null);
+ client = BASE_CLIENT
+ .newBuilder()
+ .sslSocketFactory(ssl.getSocketFactory(), trustManager)
+ .hostnameVerifier(trustManager.hostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier()))
+ .build();
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Could not set up TLS", e);
}
- req.execute(request_uri, credentials.token_b64, "register", payload);
}
- protected void GetLastClipFromServer() {
- ClientRequest req = new ClientRequest(mContext);
- String request_uri = SERVER_URI + URI_CLIP;
- req.execute(request_uri, credentials.token_b64, "get_last_clip", "");
+ public void login() {
+ execute(new ApiRequest(RequestType.LOGIN, PATH_VERIFY, null, null));
}
- protected void GetAllClipsFromServer() {
- ClientRequest req = new ClientRequest(mContext);
- String request_uri = SERVER_URI + URI_CLIP;
- req.execute(request_uri, credentials.token_b64, "get_all_clips", "");
+ public void register() {
+ execute(new ApiRequest(
+ RequestType.REGISTER,
+ PATH_REGISTER,
+ () -> new JSONObject()
+ .put("username", credentials.user)
+ .put("password", credentials.loginHash)
+ .toString(),
+ null));
}
- protected void SetClipOnServer(String clip, String format) {
- // Encrypt clip and create Payload for calling request
- if (format == null) {
- format = "txt";
- }
- ClientRequest req = new ClientRequest(mContext);
- String request_uri = SERVER_URI + URI_CLIP;
- String payload = "";
- JSONObject jsonPayload = new JSONObject();
- clip_clear = clip;
- String clip_encrypted = Utils.encryptText(mContext, clip);
- try {
- jsonPayload.put("text", clip_encrypted);
- jsonPayload.put("device", device_name);
- jsonPayload.put("format", format);
- payload = jsonPayload.toString();
- Log.d(logtag, "Parsed text to json: " + payload);
- } catch(JSONException e) {
- Log.e(logtag, "Could not parse clip text to json: " + clip);
- }
- req.execute(request_uri, credentials.token_b64, "set_clip", payload);
+ public void getLastClip() {
+ execute(new ApiRequest(RequestType.GET_LAST_CLIP, PATH_CLIP, null, null));
}
- public static void disableSSLCertChecks() {
- /** Ignore Self signed SSL Certificated - Trust all certs
- * Don't check for hostname match either
- * https://stackoverflow.com/questions/2893819/accept-servers-self-signed-ssl-certificate-in-java-client
+ public void getAllClips() {
+ execute(new ApiRequest(RequestType.GET_ALL_CLIPS, PATH_CLIP, null, null));
+ }
+
+ public void shareClip(String text, String format) {
+ execute(new ApiRequest(
+ RequestType.SET_CLIP,
+ PATH_CLIP,
+ () -> new JSONObject()
+ .put("text", Crypto.encrypt(credentials.encryptionKey, text))
+ .put("device", deviceName)
+ .put("format", format)
+ .toString(),
+ Utils.clipPreview(appContext, text, format)));
+ }
+
+ private interface SettingLookup {
+ String get();
+ }
+
+ private static String getDeviceName(Context context) {
+ /*
+ * Try to get the user defined device name
+ * Unfortunately there is no definite way: we try the most common.
+ * bluetooth_name throws a SecurityException for apps targeting Android 12L+, so each
+ * lookup is tried on its own.
*/
- Log.d(logtag, "Disabling SSL Cert Checks");
- TrustManager[] trustAllCerts = new TrustManager[] {
- new X509TrustManager() {
- public java.security.cert.X509Certificate[] getAcceptedIssuers() {
- return new X509Certificate[0];
- }
- @SuppressLint("TrustAllX509TrustManager")
- public void checkClientTrusted(
- java.security.cert.X509Certificate[] certs, String authType) {
- }
- @SuppressLint("TrustAllX509TrustManager")
- public void checkServerTrusted(
- java.security.cert.X509Certificate[] certs, String authType) {
- }
- }
+ ContentResolver cr = context.getContentResolver();
+ SettingLookup[] lookups = {
+ () -> Settings.Global.getString(cr, Settings.Global.DEVICE_NAME),
+ () -> Settings.Secure.getString(cr, "bluetooth_name"),
+ () -> Settings.System.getString(cr, "bluetooth_name"),
};
- // Install the all-trusting trust manager
- try {
- SSLContext sc = SSLContext.getInstance("SSL");
- sc.init(null, trustAllCerts, new java.security.SecureRandom());
- HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
- HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
- @SuppressLint("BadHostnameVerifier")
- @Override
- public boolean verify(String arg0, SSLSession arg1) {
- return true;
+ for (SettingLookup lookup : lookups) {
+ try {
+ String name = lookup.get();
+ if (name != null && !name.isEmpty()) {
+ return name;
}
- });
- } catch (GeneralSecurityException e) {
- Log.e(logtag, e.toString());
+ } catch (RuntimeException e) {
+ Log.d(TAG, "Device name lookup failed: " + e.getClass().getSimpleName());
+ }
}
+ return "android";
}
- public static void enableSSLCertChecks() {
- // Re-enable SSL Certification Checks
- Log.d(logtag, "Enabling SSL Cert Checks");
- // Install standard trust manager
- try {
- SSLContext sc = SSLContext.getInstance("SSL");
- sc.init(null, null, null);
- HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
- HttpsURLConnection.setDefaultHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier());
- } catch (GeneralSecurityException e) {
- Log.e(logtag, "Error enabling SSL Cert: " + e.toString());
- }
+ private void execute(ApiRequest request) {
+ notifyStarted();
+ Async.run(() -> perform(request), (result, error) -> {
+ boolean waitForUser = false;
+ try {
+ if (error != null) {
+ handleError(request, error);
+ } else {
+ waitForUser = handleResult(request, result);
+ }
+ } finally {
+ if (!waitForUser) {
+ notifyFinished();
+ }
+ }
+ });
}
- // Send request to API on server
- public static class ClientRequest extends AsyncTask {
-
- private ArrayList return_values = new ArrayList();
-
- private ClientRequest(Context context) {
- mContext = context;
+ private Result perform(ApiRequest request) throws Exception {
+ String payload = request.payload != null ? request.payload.run() : null;
+ Request.Builder builder =
+ new Request.Builder().url(credentials.server + request.path).header("Accept", "application/json");
+ if (request.type != RequestType.REGISTER) {
+ builder.header("Authorization", "Basic " + credentials.authToken);
+ }
+ if (payload != null) {
+ builder.post(RequestBody.create(payload, JSON));
}
- @Override
- protected ArrayList doInBackground(String[] params) {
- // do above Server call here
- String request_uri = params[0];
- String token = params[1];
- String req_type = params[2];
- String payload = params[3];
- StringBuilder webPage = new StringBuilder();
- String data = "";
- int response_code = 0;
- BufferedReader r_buffer;
-
- String mode = "GET";
- if (!payload.isEmpty()) {
- // if we have a payload, we post it
- mode = "POST";
+ Log.d(TAG, request.type + " " + credentials.server + request.path);
+ Result result = new Result();
+ try (okhttp3.Response response = client.newCall(builder.build()).execute()) {
+ result.code = response.code();
+ result.body = response.body().string();
+ } catch (SSLException e) {
+ result.untrustedCertificate = trustManager.rejectedCertificate();
+ if (result.untrustedCertificate == null) {
+ throw e;
+ }
+ return result;
+ } catch (IOException e) {
+ if (LocalNetwork.isPermissionMissing(appContext) && LocalNetwork.isLocalServer(credentials.server)) {
+ result.localNetworkBlocked = true;
+ return result;
}
- Log.d(logtag, request_uri + " " + mode + " " + token + " " + req_type + " " + payload);
+ throw e;
+ }
+ Log.d(TAG, "Response code: " + result.code);
+
+ if (result.isOk() && request.type != RequestType.SET_CLIP) {
+ result.clips = Clip.listFromJson(
+ result.body, credentials.encryptionKey, appContext.getString(R.string.error_decrypt_clip));
+ if (request.type == RequestType.GET_LAST_CLIP && !result.clips.isEmpty()) {
+ Clip last = result.clips.get(result.clips.size() - 1);
+ result.clipData = Utils.createClipData(appContext, last);
+ result.clipPreview = Utils.clipPreview(appContext, last.text, last.format);
+ }
+ }
+ return result;
+ }
- try {
- URL url = new URL(request_uri);
- HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
- conn.setConnectTimeout(TIMEOUT_CONN);
- conn.setRequestProperty("Content-type", "application/json; utf-8");
- conn.setRequestProperty("Accept", "application/json");
-
- if(!req_type.equals("register")) {
- // For register request don't authenticate
- conn.setRequestProperty("Authorization", "Basic " + token);
+ /** Returns true if the user is asked something, the request then finishes when the dialog is closed */
+ private boolean handleResult(ApiRequest request, Result result) {
+ if (result.untrustedCertificate != null) {
+ return askToTrustCertificate(request, result.untrustedCertificate);
+ }
+ if (result.localNetworkBlocked) {
+ showFailure(request.type, appContext.getString(R.string.error_local_network_permission));
+ return false;
+ }
+ if (!result.isOk()) {
+ showHttpError(request.type, result.code, parseErrorDetail(result.body));
+ return false;
+ }
+ switch (request.type) {
+ case LOGIN:
+ case REGISTER:
+ Utils.saveCreds(appContext, credentials);
+ toast(appContext.getString(
+ request.type == RequestType.LOGIN
+ ? R.string.msg_login_successful
+ : R.string.msg_register_successful));
+ startActivity(new Intent(context, ReadyActivity.class), true);
+ break;
+ case GET_LAST_CLIP:
+ if (result.clips.isEmpty()) {
+ toast(appContext.getString(R.string.msg_no_clip_on_server));
+ } else {
+ Utils.setClipboard(appContext, result.clipData, result.clipPreview);
}
+ break;
+ case GET_ALL_CLIPS:
+ Clips.set(result.clips);
+ startActivity(new Intent(context, ListClipsActivity.class), false);
+ break;
+ case SET_CLIP:
+ toast(appContext.getString(R.string.msg_clip_shared, request.preview));
+ break;
+ }
+ return false;
+ }
- if (mode.equals("POST")) {
- Log.d(logtag, "POST: " + payload);
- conn.setRequestMethod("POST");
- conn.setDoOutput(true);
- try (OutputStream os = conn.getOutputStream()) {
- byte[] input = payload.getBytes(StandardCharsets.UTF_8);
- os.write(input, 0, input.length);
- } catch (Exception e) {
- Log.d(logtag, "Exception in outstream: " + e);
+ private boolean askToTrustCertificate(ApiRequest request, X509Certificate certificate) {
+ if (!credentials.allowSelfSigned) {
+ showFailure(request.type, appContext.getString(R.string.error_certificate_untrusted));
+ return false;
+ }
+ if (!(context instanceof Activity)
+ || ((Activity) context).isFinishing()
+ || ((Activity) context).isDestroyed()) {
+ showFailure(request.type, appContext.getString(R.string.error_certificate_declined));
+ return false;
+ }
+ boolean changed = credentials.pinnedCertificate != null;
+ String host = URI.create(credentials.server).getHost();
+ String message = context.getString(
+ changed ? R.string.certificate_changed_message : R.string.trust_certificate_message,
+ host,
+ PinningTrustManager.fingerprint(certificate));
+ boolean[] trusted = {false};
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(changed ? R.string.certificate_changed_title : R.string.trust_certificate_title)
+ .setMessage(message)
+ .setPositiveButton(R.string.trust_certificate, (dialog, which) -> {
+ trusted[0] = true;
+ Credentials pinned = credentials.withPinnedCertificate(certificate);
+ if (request.type != RequestType.LOGIN && request.type != RequestType.REGISTER) {
+ // Login and register save the credentials once they succeed
+ Utils.saveCreds(appContext, pinned);
}
- } else if (mode.equals("GET")) {
- Log.d(logtag, "GET");
- conn.setRequestMethod("GET");
- try {
- conn.connect();
- } catch (Exception e) {
- Log.e(logtag, "Get connection: " + e);
+ // Started before this request finishes, so listeners don't see an idle state in between
+ new NetClient(context, pinned).execute(request);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .setOnDismissListener(dialog -> {
+ if (!trusted[0]) {
+ showFailure(request.type, appContext.getString(R.string.error_certificate_declined));
}
- }
-
- response_code = conn.getResponseCode();
- Log.d(logtag, "Response: " + response_code);
+ notifyFinished();
+ })
+ .show();
+ return true;
+ }
- if (response_code >= 200 && response_code <= 399) {
- // OK response
- r_buffer = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));
- } else {
- // Error response code
- r_buffer = new BufferedReader(new InputStreamReader(conn.getErrorStream(), "UTF-8"));
- }
- while ((data = r_buffer.readLine()) != null) {
- webPage.append(data).append("\n");
- }
- } catch (Exception e) {
- Log.d(logtag, "Exception: " + e);
- webPage.append(e.toString());
- }
- return_values.add(SERVER_URI);
- return_values.add(token);
- return_values.add(req_type);
- return_values.add(Integer.toString(response_code));
- return_values.add(webPage.toString());
- Log.d(logtag, "HTTP Values Answer: " + return_values);
- return return_values;
+ private void handleError(ApiRequest request, Exception error) {
+ Log.e(TAG, request.type + " failed: " + error);
+ String reason;
+ if (error instanceof SSLPeerUnverifiedException) {
+ reason = appContext.getString(R.string.error_certificate_hostname);
+ } else if (error instanceof IOException) {
+ reason = appContext.getString(R.string.error_connection, credentials.server);
+ } else if (error instanceof JSONException) {
+ reason = appContext.getString(R.string.error_invalid_response);
+ } else {
+ reason = appContext.getString(R.string.error_unexpected);
}
+ showFailure(request.type, reason);
+ }
- @Override
- protected void onPostExecute(ArrayList values) {
- Log.d(logtag, "onPostExecute: " + values);
- // Deal with responses
- handleRequestAnswer(values);
+ private void showHttpError(RequestType type, int code, String detail) {
+ String reason;
+ if (code == 401 || code == 403) {
+ reason = appContext.getString(R.string.error_unauthorized);
+ } else if (detail != null && !detail.isEmpty()) {
+ reason = detail;
+ } else {
+ reason = appContext.getString(R.string.error_http_code, code);
}
+ showFailure(type, reason);
}
- private static void handleRequestAnswer(ArrayList values) {
- // Called from NetClient class when request finishes
- // TODO: use custom Object as with Credentials might be a lot nicer
- String rsp_server_uri = "";
- String rsp_token = "";
- String rsp_req_type = "";
- String rsp_code = "";
- String rsp_msg = "";
- String message_error = null;
- JSONArray clips = null;
+ private void showFailure(RequestType type, String reason) {
+ toast(appContext.getString(R.string.error_request_failed, actionName(type), reason));
+ }
- try {
- rsp_server_uri = values.get(0).toString();
- rsp_token = values.get(1).toString();
- rsp_req_type = values.get(2).toString();
- rsp_code = values.get(3).toString();
- rsp_msg = values.get(4).toString();
- } catch (IndexOutOfBoundsException e) {
- Log.e(logtag, "not all values returned.");
- }
- Log.d(logtag, "Server: " + rsp_server_uri + " Req Type: " + rsp_req_type +
- " Token :" + rsp_token + " Resp Code: " + rsp_code + " Msg: " + rsp_msg);
-
- // Handle http response codes
- if (rsp_code.equals("200") || rsp_code.equals("201")) {
- // OK Reponses
- clips = parseJSONOKResponse(rsp_msg);
- if(rsp_req_type.equals("login")) {
- Log.d(logtag, "Login successful - saving Used credentials to file");
- Utils.saveCreds(mContext, credentials);
- Toast.makeText(mContext, mContext.getString(R.string.app_name) + " - Login successful!",
- Toast.LENGTH_LONG).show();
- startReadyActivity(mContext);
- } else if(rsp_req_type.equals("register")) {
- Log.d(logtag, "Registration successful - saving Used credentials to file");
- Utils.saveCreds(mContext, credentials);
- Toast.makeText(mContext, mContext.getString(R.string.app_name) + " - Registration successful!",
- Toast.LENGTH_LONG).show();
- startReadyActivity(mContext);
- } else if(rsp_req_type.equals("get_last_clip")) {
- if (clips.length() == 0) {
- Toast.makeText(mContext, mContext.getString(R.string.app_name) + " - No clip found on server",
- Toast.LENGTH_LONG).show();
- return;
- }
- try {
- Log.d(logtag, "Get_last_clip successful: " + clips.getJSONObject(clips.length() - 1).getString("text_decrypted"));
- Utils.setClipboard(mContext,
- clips.getJSONObject(clips.length() - 1).getString("text_decrypted"),
- clips.getJSONObject(clips.length() - 1).getString("format"));
- } catch (JSONException e) {
- Log.e(logtag, "Could not read last clip: " + e);
- }
- } else if(rsp_req_type.equals("get_all_clips")) {
- Log.d(logtag, "Get_all_clips successful");
- startListClipsActivity(mContext, clips);
- } else if(rsp_req_type.equals("set_clip")) {
- try {
- Log.d(logtag, "Set_clip successful: " + rsp_msg + "\n" + clips.getJSONObject(clips.length() - 1).getString("text_decrypted"));
- Toast.makeText(mContext, mContext.getString(R.string.app_name) + " - shared Clip:\n" + clips.getJSONObject(clips.length() - 1).getString("text_decrypted"),
- Toast.LENGTH_LONG).show();
- } catch (JSONException e) {
- Log.e(logtag, "Could not read shared clip: " + e);
- }
- }
- } else {
- // ERROR Responses
- message_error = parseJSONErrorResponse(rsp_msg);
- Toast.makeText(mContext, mContext.getString(R.string.app_name) + " - " + rsp_req_type + " failed with code: " +
- rsp_code + "\n" + "Message: " + message_error,
- Toast.LENGTH_LONG).show();
+ private String actionName(RequestType type) {
+ switch (type) {
+ case LOGIN:
+ return appContext.getString(R.string.login);
+ case REGISTER:
+ return appContext.getString(R.string.register);
+ case GET_LAST_CLIP:
+ return appContext.getString(R.string.get_last_clip);
+ case GET_ALL_CLIPS:
+ return appContext.getString(R.string.get_all_clips);
+ default:
+ return appContext.getString(R.string.set_clip);
}
}
- private static JSONArray parseJSONOKResponse(String res) {
- /**
- * Parse OK HTTP Response from JSON to ArrayList
- */
- JSONObject clip = null;
- JSONArray clips = null;
+ private static String parseErrorDetail(String body) {
+ // Server errors look like {"detail": "..."}, anything else (e.g. an HTML page) is not shown
try {
- clips = new JSONArray(res);
- clips = Utils.DecryptClips(mContext, clips);
+ return new JSONObject(body).getString("detail");
} catch (JSONException e) {
- Log.w(logtag, "Error: Could not parse as JSONArray: " + res.toString());
- Log.w(logtag, "Parsing as single Object");
- try {
- clip = new JSONObject(res);
- clips = new JSONArray();
- clips.put(clip);
- clips = Utils.DecryptClips(mContext, clips);
-
- } catch (JSONException err) {
- Log.e(logtag, "Error: could not parse Json as Array or Object.\n" + err);
- clips = new JSONArray();
- }
+ return null;
}
- Log.d(logtag, "Ok Parsing: " + clips.toString());
- return clips;
}
- private static String parseJSONErrorResponse(String res) {
- /**
- * Parse ok HTTP JSON responses and return ArrayList of Strings
- */
- String res_text = null;
- try {
- JSONObject jRes = new JSONObject(res);
- res_text = jRes.getString("detail");
- } catch(Exception JSONException) {
- res_text = res;
- Log.d(logtag, "Could not parse as JSONObject: " + res_text);
+ private void startActivity(Intent intent, boolean finishCurrent) {
+ if (context instanceof Activity) {
+ Activity activity = (Activity) context;
+ if (activity.isFinishing() || activity.isDestroyed()) {
+ return;
+ }
+ activity.startActivity(intent);
+ if (finishCurrent) {
+ activity.finish();
+ }
}
- Log.d(logtag, "OK Parsing error JSON response: " + res_text);
- return res_text;
}
- private static void startReadyActivity(Context context) {
- /**
- * After successful login switch to Ready Screen
- */
- Log.d(logtag, "startReadyActivity");
- try {
- Intent i = new Intent(context, ReadyActivity.class);
- mContext.startActivity(i);
- if (mContext instanceof Activity) {
- ((Activity) mContext).finish();
- }
- } catch (Exception e) {
- Log.e(logtag, "Exception calling: " + e);
+ private void toast(String msg) {
+ Toast.makeText(appContext, msg, Toast.LENGTH_LONG).show();
+ }
+
+ private void notifyStarted() {
+ if (context instanceof Listener) {
+ ((Listener) context).onRequestStarted();
}
}
- private static void startListClipsActivity(Context context, JSONArray clips) {
- /**
- * Start Activity to show all decrypted Clips
- */
- Log.d(logtag, "startListClipsActivity");
- try {
- Clips myClips = Clips.getInstance();
- myClips.setData(clips);
- Intent i = new Intent(context, ListClipsActivity.class);
- mContext.startActivity(i);
- } catch (Exception e) {
- Log.e(logtag, "Error starting Activity: " + e);
+ private void notifyFinished() {
+ if (context instanceof Listener) {
+ ((Listener) context).onRequestFinished();
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/PinningTrustManager.java b/app/src/main/java/com/data_dive/com/clipster/PinningTrustManager.java
new file mode 100644
index 0000000..bdb11e1
--- /dev/null
+++ b/app/src/main/java/com/data_dive/com/clipster/PinningTrustManager.java
@@ -0,0 +1,146 @@
+package com.data_dive.com.clipster;
+
+import android.annotation.SuppressLint;
+import java.net.Socket;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Locale;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
+
+/**
+ * Trusts everything the platform trusts, plus one pinned certificate the user confirmed
+ * (trust on first use, for own servers with a self-signed certificate).
+ * Remembers the certificate of a rejected server, so the user can be asked whether to trust it.
+ */
+// Not a trust-all manager: every chain goes through the platform checks first
+@SuppressLint("CustomX509TrustManager")
+final class PinningTrustManager extends X509ExtendedTrustManager {
+
+ private interface Check {
+ void run() throws CertificateException;
+ }
+
+ private final X509ExtendedTrustManager platform;
+ private final X509Certificate pinned;
+ private volatile X509Certificate rejected;
+
+ PinningTrustManager(X509Certificate pinned) throws GeneralSecurityException {
+ this(platformTrustManager(), pinned);
+ }
+
+ PinningTrustManager(X509ExtendedTrustManager platform, X509Certificate pinned) {
+ this.platform = platform;
+ this.pinned = pinned;
+ }
+
+ static X509ExtendedTrustManager platformTrustManager() throws GeneralSecurityException {
+ TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ factory.init((KeyStore) null);
+ for (TrustManager tm : factory.getTrustManagers()) {
+ if (tm instanceof X509ExtendedTrustManager) {
+ return (X509ExtendedTrustManager) tm;
+ }
+ }
+ throw new GeneralSecurityException("No X509ExtendedTrustManager available");
+ }
+
+ /** Certificate of the last server that failed the checks, null if none failed */
+ X509Certificate rejectedCertificate() {
+ return rejected;
+ }
+
+ /** Hostname checks are skipped only for the exact pinned certificate */
+ HostnameVerifier hostnameVerifier(HostnameVerifier platformVerifier) {
+ return (hostname, session) -> {
+ if (pinned != null) {
+ try {
+ if (pinned.equals(session.getPeerCertificates()[0])) {
+ return true;
+ }
+ } catch (SSLPeerUnverifiedException e) {
+ return false;
+ }
+ }
+ return platformVerifier.verify(hostname, session);
+ };
+ }
+
+ private void check(X509Certificate[] chain, Check platformCheck) throws CertificateException {
+ try {
+ platformCheck.run();
+ } catch (CertificateException e) {
+ if (pinned != null && chain != null && chain.length > 0 && pinned.equals(chain[0])) {
+ return;
+ }
+ rejected = chain != null && chain.length > 0 ? chain[0] : null;
+ throw e;
+ }
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket)
+ throws CertificateException {
+ check(chain, () -> platform.checkServerTrusted(chain, authType, socket));
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
+ throws CertificateException {
+ check(chain, () -> platform.checkServerTrusted(chain, authType, engine));
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ check(chain, () -> platform.checkServerTrusted(chain, authType));
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket)
+ throws CertificateException {
+ platform.checkClientTrusted(chain, authType, socket);
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
+ throws CertificateException {
+ platform.checkClientTrusted(chain, authType, engine);
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ platform.checkClientTrusted(chain, authType);
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return platform.getAcceptedIssuers();
+ }
+
+ /** SHA-256 fingerprint as shown by: openssl x509 -noout -fingerprint -sha256 -in cert.pem */
+ static String fingerprint(X509Certificate cert) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded());
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ if (sb.length() > 0) {
+ sb.append(':');
+ }
+ sb.append(String.format(Locale.ROOT, "%02X", b));
+ }
+ return sb.toString();
+ } catch (CertificateEncodingException e) {
+ throw new IllegalArgumentException(e);
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/ReadyActivity.java b/app/src/main/java/com/data_dive/com/clipster/ReadyActivity.java
index 8b2cd03..6b001d1 100644
--- a/app/src/main/java/com/data_dive/com/clipster/ReadyActivity.java
+++ b/app/src/main/java/com/data_dive/com/clipster/ReadyActivity.java
@@ -1,98 +1,107 @@
package com.data_dive.com.clipster;
-import androidx.appcompat.app.AppCompatActivity;
-
import android.content.Intent;
import android.os.Bundle;
-import android.util.Log;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.activity.EdgeToEdge;
+import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
/**
- * We're authenticated - Show main screen
- * Deal with button clicks -> requests
- *
+ * Main screen once we are logged in
*/
+public class ReadyActivity extends AppCompatActivity implements NetClient.Listener {
-public class ReadyActivity extends AppCompatActivity {
+ private static final int LOCAL_NETWORK_REQUEST = 1;
- public final String logtag = this.getClass().getSimpleName();
- private static final int BUTTON_DELAY = 3000;
- TextView get_last_clip, get_all_clips, set_clip, edit_creds, server;
+ private MaterialButton getLastClip, getAllClips, shareClipboard, editCreds;
+ private LinearProgressIndicator progress;
+ private int runningRequests = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
- Log.d(logtag, "onCreate");
-
+ EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
+ if (!Utils.areCredsSaved(this)) {
+ startActivity(new Intent(this, MainActivity.class));
+ finish();
+ return;
+ }
+
setContentView(R.layout.activity_ready);
- server = findViewById(R.id.active_server);
- setActiveServerAddress();
-
- get_last_clip = findViewById(R.id.get_last_clip);
- get_all_clips = findViewById(R.id.get_all_clips);
- set_clip = findViewById(R.id.set_clip);
- edit_creds = findViewById(R.id.edit_creds);
-
- get_last_clip.setOnClickListener(btnListener);
- get_last_clip.setTag("get_last_clip");
- get_all_clips.setOnClickListener(btnListener);
- get_all_clips.setTag("get_all_clips");
- set_clip.setOnClickListener(btnListener);
- set_clip.setTag("set_clip");
- edit_creds.setOnClickListener(btnListener);
- edit_creds.setTag("edit_creds");
- }
+ Credentials creds = Utils.getCreds(this);
+ ((TextView) findViewById(R.id.active_user)).setText(getString(R.string.logged_in_as, creds.user));
+ ((TextView) findViewById(R.id.active_server)).setText(creds.server);
- private View.OnClickListener btnListener = new DebouncedOnClickListener(BUTTON_DELAY, this) {
- public void onDebouncedClick(View v) {
- // Delay excessive clicks
- String action_tag = v.getTag().toString();
- Log.d(logtag, "Clicked Button: " + action_tag);
- prepareClipRequest(action_tag);
- }
- };
-
- private void setActiveServerAddress() {
- // set currently used server address
- if(Utils.areCredsSaved(this)) {
- Log.d(logtag, "Creds saved, getting active server address");
- Credentials creds = Utils.getCreds(this);
- server.setText(creds.server);
- } else {
- Log.d(logtag, "Creds not saved. Not displaying active server");
- server.setText("No saved server");
+ progress = findViewById(R.id.progress);
+ getLastClip = findViewById(R.id.get_last_clip);
+ getAllClips = findViewById(R.id.get_all_clips);
+ shareClipboard = findViewById(R.id.set_clip);
+ editCreds = findViewById(R.id.edit_creds);
+
+ getLastClip.setOnClickListener(v -> new NetClient(this).getLastClip());
+ getAllClips.setOnClickListener(v -> new NetClient(this).getAllClips());
+ shareClipboard.setOnClickListener(v -> shareClipboard());
+ editCreds.setOnClickListener(v -> {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.setAction(Intent.ACTION_EDIT);
+ startActivity(intent);
+ finish();
+ });
+
+ if (savedInstanceState == null) {
+ requestLocalNetworkIfNeeded(creds.server);
+ if (MainActivity.ACTION_GET_LAST_CLIP.equals(getIntent().getAction())) {
+ new NetClient(this).getLastClip();
+ }
}
}
- private void prepareClipRequest(String action) {
- if(action.equals("get_last_clip")) {
- Log.d(logtag,"Calling GetLastClip function");
- NetClient client = new NetClient(this);
- client.GetLastClipFromServer();
- } else if(action.equals("get_all_clips")) {
- Log.d(logtag, "Calling GetAllClips function");
- NetClient client = new NetClient(this);
- client.GetAllClipsFromServer();
- } else if(action.equals("set_clip")) {
- Log.d(logtag, "Calling SetClip function");
- String clip = Utils.checkClipboard(this);
- if (clip.isEmpty()) {
- Toast.makeText(this, getString(R.string.app_name) + " - Clipboard is empty",
- Toast.LENGTH_LONG).show();
- return;
+ /** E.g. after updating to Android 17 with a server on the local network */
+ private void requestLocalNetworkIfNeeded(String server) {
+ if (!LocalNetwork.isPermissionMissing(this)) {
+ return;
+ }
+ Async.run(() -> LocalNetwork.isLocalServer(server), (isLocal, error) -> {
+ if (Boolean.TRUE.equals(isLocal) && !isFinishing()) {
+ requestPermissions(new String[] {LocalNetwork.PERMISSION}, LOCAL_NETWORK_REQUEST);
}
- NetClient client = new NetClient(this);
- client.SetClipOnServer(clip, null);
- } else if(action.equals("edit_creds")) {
- Log.d(logtag, "Calling edit_creds -> Start Main Activity");
- Intent i = new Intent(this, MainActivity.class);
- i.setAction(Intent.ACTION_EDIT);
- startActivity(i);
- finish();
+ });
+ }
+
+ private void shareClipboard() {
+ String clip = Utils.readClipboardText(this);
+ if (clip.isEmpty()) {
+ Toast.makeText(this, R.string.msg_clipboard_empty, Toast.LENGTH_LONG)
+ .show();
+ return;
}
+ new NetClient(this).shareClip(clip, Clip.FORMAT_TXT);
+ }
+
+ @Override
+ public void onRequestStarted() {
+ runningRequests++;
+ updateLoading();
+ }
+
+ @Override
+ public void onRequestFinished() {
+ runningRequests = Math.max(0, runningRequests - 1);
+ updateLoading();
+ }
+
+ private void updateLoading() {
+ boolean loading = runningRequests > 0;
+ progress.setVisibility(loading ? View.VISIBLE : View.INVISIBLE);
+ getLastClip.setEnabled(!loading);
+ getAllClips.setEnabled(!loading);
+ shareClipboard.setEnabled(!loading);
+ editCreds.setEnabled(!loading);
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/data_dive/com/clipster/ShareActivity.java b/app/src/main/java/com/data_dive/com/clipster/ShareActivity.java
index a6b81db..267eeb7 100644
--- a/app/src/main/java/com/data_dive/com/clipster/ShareActivity.java
+++ b/app/src/main/java/com/data_dive/com/clipster/ShareActivity.java
@@ -1,105 +1,90 @@
package com.data_dive.com.clipster;
-import androidx.appcompat.app.AppCompatActivity;
-
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
-import android.util.Log;
import android.widget.Toast;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.IntentCompat;
/**
- * Add our app to "share with" menu when sharing texts. Alternative to ClipboardWatcherService
- * which does only work up to Android 9
+ * Adds Clipster to the "share with" menu for texts and images.
+ * Shows a small progress card until the clip is uploaded, then finishes.
*/
+public class ShareActivity extends AppCompatActivity implements NetClient.Listener {
-public class ShareActivity extends AppCompatActivity {
-
- public final String logtag = this.getClass().getSimpleName();
+ private int runningRequests = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
- Log.d(logtag, "Oncreate");
super.onCreate(savedInstanceState);
- // Get intent, action and MIME type
- Intent intent = getIntent();
- String action = intent.getAction();
- String type = intent.getType();
-
if (!Utils.areCredsSaved(this)) {
- Log.d(logtag, "Creds not saved. Cannot share.");
- Toast.makeText(this, getString(R.string.app_name) + " - Please log in first",
- Toast.LENGTH_LONG).show();
+ Toast.makeText(this, R.string.msg_login_first, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+ if (savedInstanceState != null) {
+ // Recreated (e.g. rotated) while uploading, the running request finishes the old instance
finish();
return;
}
- if (Intent.ACTION_SEND.equals(action) && type != null) {
- Log.d(logtag, "ACTION_SEND received. Type: " + type);
- if (type.equals("text/plain")) {
- Log.d(logtag, "text/plain received.");
- handleSharedText(intent);
- } else if(type.startsWith("image/")) {
- Log.d(logtag, "Received shared image");
- handleSharedImage(intent);
- }
- else {
- Log.d(logtag, "unknown MIME Type");
- Toast.makeText(this, "Clipster:\nCould not share file." +
- "\nUnknown type received: " + type,
- Toast.LENGTH_LONG).show();
- }
+ setContentView(R.layout.activity_share);
+
+ Intent intent = getIntent();
+ String type = intent.getType();
+ if (!Intent.ACTION_SEND.equals(intent.getAction()) || type == null) {
+ finish();
+ } else if (type.equals("text/plain")) {
+ shareText(intent);
+ } else if (type.startsWith("image/")) {
+ shareImage(intent);
+ } else {
+ Toast.makeText(this, getString(R.string.error_share_unknown_type, type), Toast.LENGTH_LONG)
+ .show();
+ finish();
}
}
- private void handleSharedImage(Intent intent) {
- Log.d(logtag, "handleSharedImage");
- Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (imageUri != null) {
- Log.d(logtag, "received Image: " + imageUri);
- String imageString = Utils.ImageUriToB64String(this, imageUri);
- if (imageString != null) {
- // Update clip to server
- ClientSetClip(imageString, "img");
- Toast.makeText(this, getString(R.string.app_name) + " got Image",
- Toast.LENGTH_LONG).show();
- }
+ private void shareImage(Intent intent) {
+ Uri imageUri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri.class);
+ if (imageUri == null) {
+ finish();
+ return;
}
- // directly finish so that our activity doesn't show in foreground
- finish();
+ // Decoding and re-encoding large images takes a while
+ Async.run(() -> Utils.imageUriToB64(this, imageUri), (image, error) -> {
+ if (image == null) {
+ Toast.makeText(this, R.string.error_open_image, Toast.LENGTH_LONG)
+ .show();
+ finish();
+ return;
+ }
+ new NetClient(this).shareClip(image, Clip.FORMAT_IMG);
+ });
}
- private void handleSharedText(Intent intent) {
- Log.d(logtag, "handleSendText");
- String shared_clip = intent.getStringExtra(Intent.EXTRA_TEXT);
- if (shared_clip != null) {
- Log.d(logtag, "received Text: " + shared_clip);
- if(shared_clip.toLowerCase().contains(getString(R.string.toggle_get_keyword))) {
- // On keyword update clip from server
- Log.d(logtag, "Keyword detected. Getting new clip from server.");
- ClientGetClip();
- } else {
- // Update clip to server
- ClientSetClip(shared_clip, "txt");
- }
- Toast.makeText(this, getString(R.string.app_name) + " got new text: " + shared_clip,
- Toast.LENGTH_LONG).show();
+ private void shareText(Intent intent) {
+ String text = intent.getStringExtra(Intent.EXTRA_TEXT);
+ if (text == null || text.isEmpty()) {
+ finish();
+ return;
}
- // directly finish so that our activity doesn't show in foreground
- finish();
+ new NetClient(this).shareClip(text, Clip.FORMAT_TXT);
}
- private void ClientSetClip(String clip, String format) {
- // Send Request for Updating the cloud clip
- NetClient client = new NetClient(this);
- client.SetClipOnServer(clip, format);
+ @Override
+ public void onRequestStarted() {
+ runningRequests++;
}
- private void ClientGetClip() {
- // Send Request for getting clip
- NetClient client = new NetClient(this);
- client.GetLastClipFromServer();
+ @Override
+ public void onRequestFinished() {
+ // A retry after trusting the server certificate starts before the first request finishes
+ runningRequests = Math.max(0, runningRequests - 1);
+ if (runningRequests == 0) {
+ finish();
+ }
}
-
}
diff --git a/app/src/main/java/com/data_dive/com/clipster/Utils.java b/app/src/main/java/com/data_dive/com/clipster/Utils.java
index 312c522..0f2bed8 100644
--- a/app/src/main/java/com/data_dive/com/clipster/Utils.java
+++ b/app/src/main/java/com/data_dive/com/clipster/Utils.java
@@ -1,48 +1,41 @@
package com.data_dive.com.clipster;
+import static android.content.Context.CLIPBOARD_SERVICE;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ContentResolver;
+import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
+import android.graphics.ImageDecoder;
import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
import android.provider.MediaStore;
import android.util.Base64;
import android.util.Log;
+import android.util.Patterns;
import android.widget.Toast;
import androidx.core.content.FileProvider;
-
-import com.amdelamar.jhash.Hash;
-import com.amdelamar.jhash.algorithms.Type;
-import com.macasaet.fernet.Key;
-import com.macasaet.fernet.StringValidator;
-import com.macasaet.fernet.Token;
-import com.macasaet.fernet.TokenValidationException;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
-import java.nio.charset.StandardCharsets;
-import java.time.Duration;
-import java.time.temporal.TemporalAmount;
-
-import static android.content.Context.CLIPBOARD_SERVICE;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Locale;
/**
- * implement common functions that are used in different Activities / Classes
+ * Common functions used by different Activities and classes.
+ * Never log credentials, hashes, tokens or clip contents here.
*/
+public final class Utils {
-public class Utils {
-
- private static String logtag = "Utils";
+ private static final String TAG = "Utils";
+ // Keys are kept from earlier versions, so saved logins keep working after updates
private static final String PREF_FILE = "pref_file";
private static final String PREF_IS_SAVED = "saved_id";
private static final String PREF_CRED_SERVER = "cred_server";
@@ -50,21 +43,16 @@ public class Utils {
private static final String PREF_CRED_LOGIN_PW_HASH = "cred_login_pw_hash";
private static final String PREF_CRED_MSG_PW_HASH = "cred_msg_pw_hash";
private static final String PREF_CRED_TOKEN = "cred_token";
- private static final String PREF_CRED_IGNORE_CERT = "cred_ignore_cert";
+ private static final String PREF_CRED_ALLOW_SELF_SIGNED = "cred_ignore_cert";
+ private static final String PREF_CRED_PINNED_CERT = "cred_pinned_cert";
- private static final Integer CRYPT_TOKEN_TTL = 3650;
- public static final Integer CRYPT_ITERS_LOGIN_HASH = 20000;
- public static final Integer CRYPT_ITERS_MSG_HASH = 10000;
- private static final Integer CRYPT_HASH_LENGTH = 32;
-
- public static final Integer MAX_CLIP_SHOW_LEN = 120;
- public static final Integer MIN_PW_LENGTH = 8;
+ public static final int MAX_CLIP_SHOW_LEN = 120;
+ public static final int MIN_PW_LENGTH = 8;
+ private Utils() {}
public static boolean areCredsSaved(Context context) {
- // Check if valid creds are saved to file already. Invalid ones (e.g. corrupted or
- // restored from a backup of an older version) are cleared so the user can log in again.
- Log.d(logtag, "AreCredsSaved");
+ // Invalid creds (e.g. corrupted or from an incompatible version) are cleared so the user can log in again
SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE);
if (!pref.getBoolean(PREF_IS_SAVED, false)) {
return false;
@@ -73,301 +61,234 @@ public static boolean areCredsSaved(Context context) {
getCreds(context);
return true;
} catch (RuntimeException e) {
- Log.e(logtag, "Saved credentials are invalid, clearing them: " + e);
+ Log.e(
+ TAG,
+ "Saved credentials are invalid, clearing them: "
+ + e.getClass().getSimpleName());
clearCreds(context);
return false;
}
}
public static void saveCreds(Context context, Credentials creds) {
- // Save Creds to shared preferences file
- Log.d(logtag, creds.toString());
- Log.d(logtag, "saveCreds - User: " + creds.user + " PW: " + creds.pw
- + " Server: " + creds.server + " Token: " + creds.token_b64
- + " Ignore Cert: " + creds.ignore_cert
- + " Login PW Hash: " + creds.login_pw_hash
- + " Msg PW Hash: " + creds.msg_pw_hash);
- SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE);
- SharedPreferences.Editor editor = pref.edit();
- editor.putString(PREF_CRED_USER, creds.user);
- editor.putString(PREF_CRED_LOGIN_PW_HASH, creds.login_pw_hash);
- editor.putString(PREF_CRED_MSG_PW_HASH, creds.msg_pw_hash);
- editor.putString(PREF_CRED_TOKEN, creds.token_b64);
- editor.putString(PREF_CRED_SERVER, creds.server);
- editor.putBoolean(PREF_CRED_IGNORE_CERT, creds.ignore_cert);
- editor.putBoolean(PREF_IS_SAVED, true);
- editor.apply();
+ Log.d(TAG, "saveCreds: " + creds);
+ context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
+ .edit()
+ .putString(PREF_CRED_USER, creds.user)
+ .putString(PREF_CRED_LOGIN_PW_HASH, creds.loginHash)
+ .putString(PREF_CRED_MSG_PW_HASH, creds.msgHash)
+ .putString(PREF_CRED_TOKEN, creds.authToken)
+ .putString(PREF_CRED_SERVER, creds.server)
+ .putBoolean(PREF_CRED_ALLOW_SELF_SIGNED, creds.allowSelfSigned)
+ .putString(PREF_CRED_PINNED_CERT, creds.encodedPinnedCertificate())
+ .putBoolean(PREF_IS_SAVED, true)
+ .apply();
}
public static Credentials getCreds(Context context) {
- // Read credentials from file and create Credentials object
SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE);
- String user = pref.getString(PREF_CRED_USER, "");
- String login_pw_hash = pref.getString(PREF_CRED_LOGIN_PW_HASH, "");
- String msg_pw_hash = pref.getString(PREF_CRED_MSG_PW_HASH, "");
- String server = pref.getString(PREF_CRED_SERVER, "");
- boolean ignore_cert = pref.getBoolean(PREF_CRED_IGNORE_CERT, false);
-
- Credentials creds = new Credentials(user, "", login_pw_hash, msg_pw_hash, server, ignore_cert);
-
- Log.d(logtag, "getCreds: " + user + " " + login_pw_hash + " " + msg_pw_hash + " "
- + server + " " + ignore_cert);
- return creds;
+ return Credentials.fromSaved(
+ pref.getString(PREF_CRED_USER, ""),
+ pref.getString(PREF_CRED_LOGIN_PW_HASH, ""),
+ pref.getString(PREF_CRED_MSG_PW_HASH, ""),
+ pref.getString(PREF_CRED_SERVER, ""),
+ pref.getBoolean(PREF_CRED_ALLOW_SELF_SIGNED, false),
+ pref.getString(PREF_CRED_PINNED_CERT, ""));
}
public static void clearCreds(Context context) {
- // Clear all saved shared pref files
- Log.d(logtag, "Clearning creds from file");
- SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE);
- SharedPreferences.Editor editor = pref.edit();
- editor.clear().commit();
+ context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .apply();
}
- public static void setClipboard(Context context, String clip_text, String clip_format) {
- // Copy content to clipboard and display message
- String clip_text_show = "";
- ClipData clip = null;
- ClipboardManager cb = (ClipboardManager) context.getSystemService(CLIPBOARD_SERVICE);
+ public static String clipPreview(Context context, String text, String format) {
+ if (Clip.FORMAT_IMG.equals(format)) {
+ return context.getString(R.string.clip_preview_image);
+ }
+ if (text.length() > MAX_CLIP_SHOW_LEN) {
+ return text.substring(0, MAX_CLIP_SHOW_LEN) + " [...]";
+ }
+ return text;
+ }
- if (clip_format.equals("txt")) {
- clip_text_show = clip_text.substring(0, Math.min(clip_text.length(), MAX_CLIP_SHOW_LEN));
- if (clip_text.length() > MAX_CLIP_SHOW_LEN) {
- clip_text_show = clip_text_show + " [...]";
- }
- Log.d(logtag, "Storing text to clipboard: " + clip_text);
- clip = ClipData.newPlainText("Clipster", clip_text);
- } else if (clip_format.equals("img")) {
- Log.d(logtag, "Not implemented for images, because not supported by most Apps");
- Bitmap image = Utils.B64StringToImage(clip_text);
- if (image != null) {
- Uri imageUri = BitmapToTempFileAsUri(context, image);
- clip = ClipData.newRawUri("Clipster Image", imageUri);
- clip_text_show = "Image";
+ /**
+ * Create the clipboard content for a clip. Images are decoded and written to a temp file,
+ * so call this in the background. Returns null if the clip can't be converted.
+ */
+ public static ClipData createClipData(Context context, Clip clip) {
+ if (clip.isImage()) {
+ Uri imageUri = bitmapToTempFileUri(context, b64ToBitmap(clip.text));
+ if (imageUri == null) {
+ return null;
}
+ return ClipData.newUri(context.getContentResolver(), "Clipster Image", imageUri);
}
+ return ClipData.newPlainText("Clipster", clip.text);
+ }
+ public static void setClipboard(Context context, ClipData clip, String preview) {
if (clip == null) {
- Log.e(logtag, "Could not create clip for format: " + clip_format);
- Toast.makeText(context, context.getString(R.string.app_name) + " - Could not set clipboard",
- Toast.LENGTH_LONG).show();
+ Toast.makeText(context, R.string.error_set_clipboard, Toast.LENGTH_LONG)
+ .show();
return;
}
+ ClipboardManager cb = (ClipboardManager) context.getSystemService(CLIPBOARD_SERVICE);
cb.setPrimaryClip(clip);
- // Show Preview
- Toast.makeText(context, context.getString(R.string.app_name) + " - set Clipboard to:\n"
- + clip_text_show, Toast.LENGTH_LONG).show();
+ // From Android 13 on the system shows its own confirmation with a preview
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Toast.makeText(context, context.getString(R.string.msg_clipboard_set, preview), Toast.LENGTH_LONG)
+ .show();
+ }
}
- public static String checkClipboard(Context context) {
- Log.d(logtag, "checkClipboard");
- String clip = "";
+ public static String readClipboardText(Context context) {
ClipboardManager cb = (ClipboardManager) context.getSystemService(CLIPBOARD_SERVICE);
ClipData cd = cb.getPrimaryClip();
if (cd != null && cd.getItemCount() > 0) {
// coerceToText also handles HTML and URI clips, where getText() may be null
CharSequence text = cd.getItemAt(0).coerceToText(context);
if (text != null) {
- clip = text.toString();
+ return text.toString();
}
- Log.d(logtag, "Clipboard checked:\n" + clip);
}
- return clip;
+ return "";
}
- public static String stringToHash(String username, String text, Integer iters) {
- /**
- * Create a hash representation of text using PBKDF2
- */
- String hash = "";
- String salt_string = "clipster_"+username+"_"+text;
- try {
- byte[] salt = salt_string.getBytes(StandardCharsets.UTF_8);
- String h = Hash.password(text.toCharArray())
- .algorithm(Type.PBKDF2_SHA256)
- .salt(salt)
- .hashLength(CRYPT_HASH_LENGTH)
- .factor(iters)
- .create();
- hash = h.split(":")[6]; // jhash returns 7 concatenated strings, last one is hash
- Log.d(logtag, "JHash output: " + h);
- } catch (Exception e) {
- Log.e(logtag, "Error creating Hash for Key: " + e);
+ public static boolean isValidServerUri(String serverUri) {
+ if (serverUri.startsWith("https://localhost")) {
+ return true;
}
- // jHash uses b64 standard i.e. not urlsafe encoding "/" "+" -> "_" "-"
- hash = hash.replace("/","_").replace("+", "-");
- Log.d(logtag, "Hash : " + hash);
- return hash;
- }
-
- public static String encryptText(Context context, String text) {
- // Encrypt Text with Fernet using key and output b64 representation
- Credentials credentials = getCreds(context);
- Key key = credentials.encryption_key;
- Token token = Token.generate(key, text);
- return token.serialise();
+ return Patterns.WEB_URL.matcher(serverUri).matches();
}
- public static String decryptClip(Context context, String text) {
- // Decrypt Text with Fernet using key and return it as string
- Credentials credentials = getCreds(context);
- Key key = credentials.encryption_key;
- String cleartext = "";
-
- StringValidator validator = new StringValidator() {
- @Override
- public TemporalAmount getTimeToLive() {
- // need to overwrite in order to set ttl of token
- return Duration.ofDays(CRYPT_TOKEN_TTL);
- }
- };
-
- try {
- Token token = Token.fromString(text);
- cleartext = token.validateAndDecrypt(key, validator);
- } catch (TokenValidationException e) {
- Log.e(logtag, "Decrypt Error:" + e);
- cleartext = "ERROR: Could not decrypt clip";
- } catch (RuntimeException e) {
- // Token.fromString throws IllegalArgumentException on malformed tokens
- Log.e(logtag, "Decrypt Error, malformed token: " + e);
- cleartext = "ERROR: Could not decrypt clip";
+ /**
+ * Make sure we always use https:// and have no trailing slashes
+ */
+ public static String formatServerUri(String serverUri) {
+ String uri = serverUri.trim().replaceFirst("/+$", "");
+ String lower = uri.toLowerCase(Locale.ROOT);
+ if (lower.startsWith("https://")) {
+ return "https://" + uri.substring("https://".length());
}
- Log.d(logtag, "CRYPTO: " + cleartext);
- return cleartext;
- }
-
- public static boolean validateServerURI(String server_uri) {
- /**
- * Basic validity check for the server address
- */
- if(server_uri.startsWith("https://localhost")) { return true; }
- return android.util.Patterns.WEB_URL.matcher(server_uri).matches();
+ if (lower.startsWith("http://")) {
+ return "https://" + uri.substring("http://".length());
+ }
+ return "https://" + uri;
}
- public static String formatURIProtocol(String server_uri) {
- /**
- * Make sure we always use https:// and have no trailing slashes in URI
- */
- server_uri = server_uri.replaceFirst("/*$", "");
+ /** Decode a base64 image, null if it's not a valid image */
+ public static Bitmap b64ToBitmap(String b64) {
try {
- if (server_uri.substring(0, 7).toLowerCase().startsWith("http://")) {
- Log.d(logtag, "server contains http:// replacing with https://");
- server_uri = server_uri.replace("http://", "https://");
- } else if (!server_uri.substring(0, 8).toLowerCase().startsWith("https://")) {
- Log.w(logtag, "No protocol provided, adding https://");
- server_uri = "https://" + server_uri;
- }
- return server_uri;
- } catch (Exception e) {
- Log.e(logtag, e.toString());
- return server_uri;
+ byte[] bytes = Base64.decode(b64, Base64.DEFAULT);
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ } catch (IllegalArgumentException e) {
+ Log.e(TAG, "Invalid base64 image: " + e);
+ return null;
}
}
- public static Bitmap B64StringToImage(String imgString) {
- // Decode base64 string containing image to Bitmap image and return
- Bitmap decodedImage = null;
+ /**
+ * Decode a base64 image downsampled by powers of two, while keeping the longer side at least
+ * maxSize pixels. Saves a lot of memory for list thumbnails.
+ */
+ public static Bitmap b64ToThumbnail(String b64, int maxSize) {
try {
- byte[] imgBytes = Base64.decode(imgString, Base64.DEFAULT);
- decodedImage = BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.length);
- } catch (Exception e) {
- Log.e(logtag, "Error B64StringToImage:" + e);
- // TODO: Return placeholder image if no valid image found
+ byte[] bytes = Base64.decode(b64, Base64.DEFAULT);
+ BitmapFactory.Options opts = new BitmapFactory.Options();
+ opts.inJustDecodeBounds = true;
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opts);
+ int sample = 1;
+ while (Math.max(opts.outWidth, opts.outHeight) / (sample * 2) >= maxSize) {
+ sample *= 2;
+ }
+ opts.inJustDecodeBounds = false;
+ opts.inSampleSize = sample;
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opts);
+ } catch (IllegalArgumentException e) {
+ Log.e(TAG, "Invalid base64 image: " + e);
+ return null;
}
- return decodedImage;
}
- public static String BitmapToB64String(Bitmap imageBitmap) {
- // Encode Bitmap Image to a base64 string PNG, null if there is no image
- if (imageBitmap == null) {
+ /** Encode an image as base64 PNG, null if there is no image */
+ public static String bitmapToB64(Bitmap bitmap) {
+ if (bitmap == null) {
return null;
}
- byte[] imageBytes = null;
- try {
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- imageBitmap.compress(Bitmap.CompressFormat.PNG, 100, bos);
- imageBytes = bos.toByteArray();
- } catch (Exception e) {
- Log.e(logtag, "Error BitmapToB64String:" + e);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)) {
+ Log.e(TAG, "Could not compress image");
return null;
}
- return Base64.encodeToString(imageBytes, Base64.DEFAULT);
+ return Base64.encodeToString(bos.toByteArray(), Base64.DEFAULT);
}
- public static JSONArray DecryptClips(Context mContext, JSONArray clips) {
- // DecryptClips from encrypted text to cleartext, add text_decrypted key to clips
- String text;
- JSONObject clip;
-
- for(int i=0;i= Build.VERSION_CODES.P) {
+ // Software bitmap, hardware bitmaps can't be compressed to PNG
+ return ImageDecoder.decodeBitmap(
+ ImageDecoder.createSource(cr, imageUri),
+ (decoder, info, source) -> decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE));
+ }
+ return MediaStore.Images.Media.getBitmap(cr, imageUri);
+ } catch (IOException | SecurityException e) {
+ Log.e(TAG, "Error reading shared image: " + e);
+ return null;
}
- return imageBitmap;
}
- public static String ImageUriToB64String(Context mContext, Uri imageUri) {
- // Get Image from URI and transform to B64 encoded Image
- Bitmap imageBitmap = ImageUriToBitmap(mContext, imageUri);
- return BitmapToB64String(imageBitmap);
+ public static String imageUriToB64(Context context, Uri imageUri) {
+ return bitmapToB64(imageUriToBitmap(context, imageUri));
}
- public static void SaveBitmapToGallery(Context mContext, Bitmap image, String title, String description) {
- // SaveBitmapToGallery and show Toast on success
- // TODO: Add date to filename so not to overwrite?
- try {
- ContentResolver cr = mContext.getContentResolver();
- if (image == null || MediaStore.Images.Media.insertImage(cr, image, title, description) == null) {
- throw new IllegalStateException("insertImage failed");
+ /**
+ * Save an image to the gallery (Pictures/Clipster from Android 10 on). Returns true on success.
+ */
+ public static boolean saveBitmapToGallery(Context context, Bitmap image) {
+ if (image == null) {
+ return false;
+ }
+ ContentResolver cr = context.getContentResolver();
+ String name = "clipster_" + System.currentTimeMillis() + ".png";
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
+ values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
+ values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Clipster");
+ Uri uri = cr.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
+ if (uri == null) {
+ return false;
}
- } catch(Exception e) {
- Toast.makeText(mContext, "Error:\nCould not save Image to Gallery", Toast.LENGTH_LONG).show();
- Log.e(logtag, "Error: " + e);
- return;
+ try (OutputStream os = cr.openOutputStream(uri)) {
+ if (os != null && image.compress(Bitmap.CompressFormat.PNG, 100, os)) {
+ return true;
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Error saving image: " + e);
+ }
+ cr.delete(uri, null, null);
+ return false;
}
- Toast.makeText(mContext, "Image saved to Gallery", Toast.LENGTH_LONG).show();
- Log.d(logtag, "Saved image to gallery: " + title + " " + description);
+ return MediaStore.Images.Media.insertImage(cr, image, name, "Image shared via Clipster") != null;
}
-
}
diff --git a/app/src/main/res/drawable-nodpi/clipster_250_banner.png b/app/src/main/res/drawable-nodpi/clipster_250_banner.png
new file mode 100644
index 0000000..c250d5f
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/clipster_250_banner.png differ
diff --git a/app/src/main/res/drawable/clipster_250_banner.png b/app/src/main/res/drawable/clipster_250_banner.png
deleted file mode 100644
index f556f8b..0000000
Binary files a/app/src/main/res/drawable/clipster_250_banner.png and /dev/null differ
diff --git a/app/src/main/res/drawable/cloud.png b/app/src/main/res/drawable/cloud.png
deleted file mode 100644
index 0686f41..0000000
Binary files a/app/src/main/res/drawable/cloud.png and /dev/null differ
diff --git a/app/src/main/res/drawable/grdnt.xml b/app/src/main/res/drawable/grdnt.xml
deleted file mode 100644
index 464358c..0000000
--- a/app/src/main/res/drawable/grdnt.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml
new file mode 100644
index 0000000..cd06f30
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_back.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml
new file mode 100644
index 0000000..8e7143f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml
new file mode 100644
index 0000000..31decae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lock.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml
new file mode 100644
index 0000000..07eeb5a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/message.png b/app/src/main/res/drawable/message.png
deleted file mode 100644
index aa940db..0000000
Binary files a/app/src/main/res/drawable/message.png and /dev/null differ
diff --git a/app/src/main/res/drawable/open_padlock.png b/app/src/main/res/drawable/open_padlock.png
deleted file mode 100644
index 7ee8c01..0000000
Binary files a/app/src/main/res/drawable/open_padlock.png and /dev/null differ
diff --git a/app/src/main/res/drawable/phonemo.png b/app/src/main/res/drawable/phonemo.png
deleted file mode 100644
index a18d698..0000000
Binary files a/app/src/main/res/drawable/phonemo.png and /dev/null differ
diff --git a/app/src/main/res/drawable/pswrd.png b/app/src/main/res/drawable/pswrd.png
deleted file mode 100644
index 2fc38aa..0000000
Binary files a/app/src/main/res/drawable/pswrd.png and /dev/null differ
diff --git a/app/src/main/res/drawable/usrusr.png b/app/src/main/res/drawable/usrusr.png
deleted file mode 100644
index 556d2bf..0000000
Binary files a/app/src/main/res/drawable/usrusr.png and /dev/null differ
diff --git a/app/src/main/res/layout/activity_list_clips.xml b/app/src/main/res/layout/activity_list_clips.xml
index 24ad197..8d128fa 100644
--- a/app/src/main/res/layout/activity_list_clips.xml
+++ b/app/src/main/res/layout/activity_list_clips.xml
@@ -1,73 +1,40 @@
-
+
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:orientation="vertical">
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+ android:padding="@dimen/screen_padding"
+ android:text="@string/empty_clip_list"
+ android:textAppearance="?attr/textAppearanceBodyLarge"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="gone" />
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 5421aaa..c3e107e 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,171 +1,122 @@
-
-
-
+ android:layout_height="wrap_content">
+
-
+
+
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
+
+
+
-
+
+
-
-
-
+ android:layout_marginTop="@dimen/spacing_medium"
+ android:text="@string/login" />
+
+
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/layout/activity_ready.xml b/app/src/main/res/layout/activity_ready.xml
index 7a4e70a..15e6f9b 100644
--- a/app/src/main/res/layout/activity_ready.xml
+++ b/app/src/main/res/layout/activity_ready.xml
@@ -1,116 +1,116 @@
-
-
-
+ android:layout_height="wrap_content">
+
-
+
+
+
+
+
-
-
+
+
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:padding="@dimen/spacing_medium">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml
index 9814864..53ba62d 100644
--- a/app/src/main/res/layout/activity_share.xml
+++ b/app/src/main/res/layout/activity_share.xml
@@ -1,9 +1,32 @@
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_clips_items.xml b/app/src/main/res/layout/list_clips_items.xml
deleted file mode 100644
index 40d147c..0000000
--- a/app/src/main/res/layout/list_clips_items.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_single_img.xml b/app/src/main/res/layout/list_single_img.xml
index 242b6c7..7d17b2f 100644
--- a/app/src/main/res/layout/list_single_img.xml
+++ b/app/src/main/res/layout/list_single_img.xml
@@ -1,16 +1,30 @@
-
+
-
-
-
-
\ No newline at end of file
+ android:layout_height="wrap_content"
+ android:minHeight="48dp"
+ android:orientation="vertical"
+ android:paddingHorizontal="@dimen/spacing_medium"
+ android:paddingVertical="12dp">
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_single_txt.xml b/app/src/main/res/layout/list_single_txt.xml
index da9a7c3..e1f3483 100644
--- a/app/src/main/res/layout/list_single_txt.xml
+++ b/app/src/main/res/layout/list_single_txt.xml
@@ -1,15 +1,27 @@
-
-
-
-
-
\ No newline at end of file
+ android:layout_height="wrap_content"
+ android:minHeight="48dp"
+ android:orientation="vertical"
+ android:paddingHorizontal="@dimen/spacing_medium"
+ android:paddingVertical="12dp">
+
+
+
+
+
diff --git a/app/src/main/res/menu/popup_menu_image.xml b/app/src/main/res/menu/popup_menu_image.xml
index 9828387..a9e803f 100644
--- a/app/src/main/res/menu/popup_menu_image.xml
+++ b/app/src/main/res/menu/popup_menu_image.xml
@@ -6,4 +6,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/menu/popup_menu_text.xml b/app/src/main/res/menu/popup_menu_text.xml
index 4725dbc..c857732 100644
--- a/app/src/main/res/menu/popup_menu_text.xml
+++ b/app/src/main/res/menu/popup_menu_text.xml
@@ -6,4 +6,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
similarity index 56%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml
index 036d09b..c679ec4 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -1,5 +1,7 @@
-
-
-
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
similarity index 71%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
index 036d09b..6f37998 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -1,5 +1,6 @@
-
-
-
\ No newline at end of file
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index f6eee02..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index fe65f3d..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 0b0c04e..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index 2498069..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 4f63baf..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index 6fdf029..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 3f6b6b2..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index aba12a5..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index a72a9b3..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index f625393..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..7741a79
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,18 @@
+
+
+ #FFFFFF
+ #000000
+ #3A3A3A
+ #EDEDED
+ #333333
+ #EDEDED
+ #121212
+ #E6E6E6
+ #C6C6C6
+ #1B1B1B
+ #1F1F1F
+ #292929
+ #333333
+ #8F8F8F
+ #444444
+
diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml
deleted file mode 100644
index cb40177..0000000
--- a/app/src/main/res/values-v35/styles.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index efc0798..8944754 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,6 +1,19 @@
-
-
- #000
- #000
- #FF4081
-
+
+
+
+ #000000
+ #FFFFFF
+ #E2E2E2
+ #1B1B1B
+ #EAEAEA
+ #1B1B1B
+ #FFFFFF
+ #1B1B1B
+ #474747
+ #F6F6F6
+ #F0F0F0
+ #EAEAEA
+ #E4E4E4
+ #767676
+ #D0D0D0
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index f31eb24..107c4fe 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -1,6 +1,9 @@
-
-
- 16dp
- 16dp
- 16dp
-
+
+ 24dp
+
+ 480dp
+ 8dp
+ 16dp
+ 32dp
+ 200dp
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
index c5d5899..f42ada6 100644
--- a/app/src/main/res/values/ic_launcher_background.xml
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
#FFFFFF
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cab7f65..82e7bb6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,26 +1,81 @@
-
- Clipster
- Login
- Register
- https://clipster.cc
- Disable SSL certificate check (only use with own server)
- clipster
- Get last Clip
- Get all Clips
- Share Clipboard
- Edit Credentials
- Please wait a few seconds before retrying
- MainActivity
-
- First Fragment
- Second Fragment
- Next
- Previous
- Slow down! Your clicking too fast
- Copy to Clipboard
- Back
- There are no shared Clips yet
- Copy to Clipboard
- Save to Gallery
- Share
-
+
+ Clipster
+ https://clipster.cc
+
+
+ Login
+ Register
+ Username
+ Password
+ Server
+ Leave empty to use the public server
+ Allow self-signed certificate (own server only)
+ Please enter a username
+ Please enter a password
+
+ - Must be at least %d character long
+ - Must be at least %d characters long
+
+ Invalid address, e.g. https://clipster.cc:9999
+ Could not create encryption key from password
+
+
+ Logged in as %s
+ Get last Clip
+ Get all Clips
+ Share Clipboard
+ Edit Credentials
+
+
+ Back
+ There are no shared Clips yet
+ Copy to Clipboard
+ Save to Gallery
+ Share
+ Share Clip
+ Shared image
+
+ %1$s · %2$s
+ just now
+
+
+ Trust this server?
+ %1$s uses a certificate that Android doesn\'t trust.\n\nSHA-256 fingerprint:\n%2$s\n\nOnly trust it if this is your own server and the fingerprint matches its certificate.
+ Server certificate changed
+ The certificate of %1$s is not the one you trusted before. Someone might be intercepting your connection.\n\nNew SHA-256 fingerprint:\n%2$s\n\nOnly trust it if you replaced the certificate of your server yourself.
+ Trust
+
+
+ Sharing…
+ Clipster: please log in first
+ Clipster can\'t share this type: %s
+ Could not open the shared image
+
+
+ Get last Clip
+ Copy the last shared Clip
+
+
+ Login successful
+ Registration successful
+ No Clip found on the server
+ Shared:\n%s
+ Copied to clipboard:\n%s
+ Your clipboard is empty
+ Image saved to Gallery
+ Image
+ %1$s failed: %2$s
+ could not reach %s
+ wrong username or password
+ server error (HTTP %d)
+ unexpected error
+ invalid response from the server
+
+ untrusted certificate. Own server? Allow self-signed certificates.
+ the server certificate wasn\'t trusted
+ the server certificate doesn\'t match the server address
+ your server is on the local network. Allow Clipster to access \"Nearby devices\" in the app settings.
+ ERROR: Could not decrypt Clip. Was it encrypted with another password?
+ Could not copy the Clip to the clipboard
+ Could not save the image to the Gallery
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index 8402cc4..0000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..43c1850
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..48ae1f9
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..d6171af
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index e202e99..376b60f 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -1,4 +1,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
deleted file mode 100644
index 135200b..0000000
--- a/app/src/main/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- 10.0.2.2
- 127.0.0.1
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..08bde21
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/data_dive/com/clipster/ClipTest.java b/app/src/test/java/com/data_dive/com/clipster/ClipTest.java
new file mode 100644
index 0000000..4d4e213
--- /dev/null
+++ b/app/src/test/java/com/data_dive/com/clipster/ClipTest.java
@@ -0,0 +1,84 @@
+package com.data_dive.com.clipster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.macasaet.fernet.Key;
+import java.time.Instant;
+import java.util.List;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Test;
+
+public class ClipTest {
+
+ private static final Key KEY = new Key("BYVy_Lfm1pO5uSEQRq3UmHDegjhE2RpFt60PVZJjfp4=");
+
+ private static JSONObject clipJson(String text, String format) throws Exception {
+ return new JSONObject()
+ .put("id", 1)
+ .put("user", "alice")
+ .put("text", text)
+ .put("format", format)
+ .put("device", "desktop")
+ .put("created_at", "2026-09-28T19:55:18.431234Z");
+ }
+
+ @Test
+ public void listFromJson_decryptsArrayWithDeviceAndTimestamp() throws Exception {
+ String body = new JSONArray()
+ .put(clipJson(Crypto.encrypt(KEY, "first"), "txt"))
+ .put(clipJson(Crypto.encrypt(KEY, "iVBOR"), "img"))
+ .toString();
+
+ List clips = Clip.listFromJson(body, KEY, "error");
+
+ assertEquals(2, clips.size());
+ assertEquals("first", clips.get(0).text);
+ assertEquals("desktop", clips.get(0).device);
+ assertEquals(Instant.parse("2026-09-28T19:55:18.431234Z"), clips.get(0).createdAt);
+ assertTrue(clips.get(1).isImage());
+ }
+
+ @Test
+ public void listFromJson_acceptsSingleObject() throws Exception {
+ String body = clipJson(Crypto.encrypt(KEY, "only"), "txt").toString();
+ assertEquals("only", Clip.listFromJson(body, KEY, "error").get(0).text);
+ }
+
+ @Test
+ public void listFromJson_badClipDoesNotHideOthers() throws Exception {
+ String body = new JSONArray()
+ .put(clipJson("garbage", "img"))
+ .put(clipJson(Crypto.encrypt(KEY, "ok"), "txt"))
+ .toString();
+
+ List clips = Clip.listFromJson(body, KEY, "error");
+
+ assertEquals("error", clips.get(0).text);
+ // Shown as text, not handed to the image decoder
+ assertEquals(Clip.FORMAT_TXT, clips.get(0).format);
+ assertEquals("ok", clips.get(1).text);
+ }
+
+ @Test
+ public void listFromJson_missingOptionalFields() throws Exception {
+ String body = new JSONArray()
+ .put(new JSONObject().put("text", Crypto.encrypt(KEY, "old server")))
+ .toString();
+
+ Clip clip = Clip.listFromJson(body, KEY, "error").get(0);
+
+ assertEquals(Clip.FORMAT_TXT, clip.format);
+ assertEquals("", clip.device);
+ assertNull(clip.createdAt);
+ }
+
+ @Test
+ public void parseTimestamp_acceptsOffsetsAndRejectsGarbage() {
+ assertEquals(Instant.parse("2026-09-28T17:55:18Z"), Clip.parseTimestamp("2026-09-28T19:55:18+02:00"));
+ assertNull(Clip.parseTimestamp("yesterday"));
+ assertNull(Clip.parseTimestamp(""));
+ }
+}
diff --git a/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java b/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java
new file mode 100644
index 0000000..760cc3c
--- /dev/null
+++ b/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java
@@ -0,0 +1,88 @@
+package com.data_dive.com.clipster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import com.macasaet.fernet.Key;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import org.junit.Test;
+
+/**
+ * Expected values were generated independently of this code base with Python's hashlib and
+ * cryptography.fernet, following deriveKey() in Clipster-Desktop (clipster/crypt.go):
+ * urlsafe_b64encode(pbkdf2_hmac("sha256", pw, "clipster_" + user + "_" + pw, iters, 32))
+ * If these tests fail, Android can no longer talk to the other Clipster clients.
+ */
+public class CryptoTest {
+
+ private static final String USER = "alice";
+ private static final String PASSWORD = "correct horse battery";
+ private static final String LOGIN_HASH = "bBthXg9OHtoBhzVQaCvJ-tT6SBNm3k_8d84OEDVjl8E=";
+ private static final String MSG_HASH = "BYVy_Lfm1pO5uSEQRq3UmHDegjhE2RpFt60PVZJjfp4=";
+
+ // Fernet token of "Hello from Clipster-Desktop ✂" with MSG_HASH, created at 2026-09-01T00:00:00Z
+ private static final String TOKEN = "gAAAAABqlhWAtRsghjyq9rsI8g-UaikhzJTWCAwmUsd7AKLqbKwOMiXkmJMqdQ_9E0y"
+ + "MrasS8a9ZJrocwqrpA_CO4cog538pynJdM8PcZ6BnfJ4PYgL0wAQ=";
+ private static final Clock AFTER_TOKEN_CREATION =
+ Clock.fixed(Instant.parse("2026-09-02T00:00:00Z"), ZoneOffset.UTC);
+
+ @Test
+ public void deriveKey_loginHash_matchesOtherClients() {
+ assertEquals(LOGIN_HASH, Crypto.deriveKey(USER, PASSWORD, Crypto.ITERS_LOGIN_HASH));
+ }
+
+ @Test
+ public void deriveKey_msgHash_matchesOtherClients() {
+ assertEquals(MSG_HASH, Crypto.deriveKey(USER, PASSWORD, Crypto.ITERS_MSG_HASH));
+ }
+
+ @Test
+ public void deriveKey_nonAsciiPassword_isUtf8Encoded() {
+ assertEquals(
+ "da807iYOiUZngQv_La7PBS0OBX4t-4mXVIKqlPWkVMg=",
+ Crypto.deriveKey("bob", "pässwörd-ü€", Crypto.ITERS_LOGIN_HASH));
+ assertEquals(
+ "U5apiIs8RYDrzHrpqGNxtGf0w1MfIJaZYpx4RRTd0WE=",
+ Crypto.deriveKey("bob", "pässwörd-ü€", Crypto.ITERS_MSG_HASH));
+ }
+
+ @Test
+ public void authToken_isBase64OfUserAndLoginHash() {
+ assertEquals(
+ "YWxpY2U6YkJ0aFhnOU9IdG9CaHpWUWFDdkotdFQ2U0JObTNrXzhkODRPRURWamw4RT0=",
+ Crypto.authToken(USER, LOGIN_HASH));
+ }
+
+ @Test
+ public void decrypt_tokenFromOtherClient() {
+ assertEquals("Hello from Clipster-Desktop ✂", Crypto.decrypt(new Key(MSG_HASH), TOKEN, AFTER_TOKEN_CREATION));
+ }
+
+ @Test
+ public void encrypt_thenDecrypt_roundTrips() {
+ Key key = new Key(MSG_HASH);
+ String text = "multi\nline ✂ clip";
+ assertEquals(text, Crypto.decrypt(key, Crypto.encrypt(key, text)));
+ }
+
+ @Test
+ public void decrypt_withWrongKey_throws() {
+ Key wrongKey = new Key(LOGIN_HASH);
+ assertThrows(RuntimeException.class, () -> Crypto.decrypt(wrongKey, TOKEN, AFTER_TOKEN_CREATION));
+ }
+
+ @Test
+ public void decrypt_malformedToken_throws() {
+ assertThrows(RuntimeException.class, () -> Crypto.decrypt(new Key(MSG_HASH), "not a token"));
+ }
+
+ @Test
+ public void credentials_fromPassword_matchesSavedHashes() {
+ Credentials fromPw = Credentials.fromPassword(USER, PASSWORD, "https://clipster.cc", false);
+ assertEquals(LOGIN_HASH, fromPw.loginHash);
+ assertEquals(MSG_HASH, fromPw.msgHash);
+ assertEquals(Crypto.authToken(USER, LOGIN_HASH), fromPw.authToken);
+ }
+}
diff --git a/app/src/test/java/com/data_dive/com/clipster/LocalNetworkTest.java b/app/src/test/java/com/data_dive/com/clipster/LocalNetworkTest.java
new file mode 100644
index 0000000..cd113ba
--- /dev/null
+++ b/app/src/test/java/com/data_dive/com/clipster/LocalNetworkTest.java
@@ -0,0 +1,37 @@
+package com.data_dive.com.clipster;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.net.InetAddress;
+import org.junit.Test;
+
+public class LocalNetworkTest {
+
+ private static boolean isLocal(String ip) throws Exception {
+ // Literal addresses are parsed without a DNS lookup
+ return LocalNetwork.isLocalAddress(InetAddress.getByName(ip));
+ }
+
+ @Test
+ public void privateAndLinkLocalAddresses_areLocal() throws Exception {
+ assertTrue(isLocal("192.168.1.20"));
+ assertTrue(isLocal("10.0.2.2"));
+ assertTrue(isLocal("172.16.5.1"));
+ assertTrue(isLocal("169.254.1.1"));
+ assertTrue(isLocal("fd12:3456::1"));
+ assertTrue(isLocal("fe80::1"));
+ }
+
+ @Test
+ public void publicAndLoopbackAddresses_areNotLocal() throws Exception {
+ assertFalse(isLocal("93.184.216.34"));
+ assertFalse(isLocal("2606:4700::1111"));
+ assertFalse(isLocal("127.0.0.1"));
+ }
+
+ @Test
+ public void mdnsHostnames_areLocalWithoutLookup() {
+ assertTrue(LocalNetwork.isLocalServer("https://clipster.local:9999"));
+ }
+}
diff --git a/app/src/test/java/com/data_dive/com/clipster/PinningTrustManagerTest.java b/app/src/test/java/com/data_dive/com/clipster/PinningTrustManagerTest.java
new file mode 100644
index 0000000..0877d71
--- /dev/null
+++ b/app/src/test/java/com/data_dive/com/clipster/PinningTrustManagerTest.java
@@ -0,0 +1,78 @@
+package com.data_dive.com.clipster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import okhttp3.tls.HeldCertificate;
+import org.junit.Test;
+
+public class PinningTrustManagerTest {
+
+ private static X509Certificate selfSigned() {
+ return new HeldCertificate.Builder()
+ .commonName("my-own-server")
+ .rsa2048()
+ .build()
+ .certificate();
+ }
+
+ @Test
+ public void unpinnedSelfSignedCertificate_isRejectedAndRemembered() throws Exception {
+ X509Certificate cert = selfSigned();
+ PinningTrustManager tm = new PinningTrustManager(null);
+
+ assertThrows(CertificateException.class, () -> tm.checkServerTrusted(new X509Certificate[] {cert}, "RSA"));
+ assertEquals(cert, tm.rejectedCertificate());
+ }
+
+ @Test
+ public void pinnedCertificate_isTrusted() throws Exception {
+ X509Certificate cert = selfSigned();
+ PinningTrustManager tm = new PinningTrustManager(cert);
+
+ tm.checkServerTrusted(new X509Certificate[] {cert}, "RSA");
+ assertNull(tm.rejectedCertificate());
+ }
+
+ @Test
+ public void otherCertificate_isRejectedEvenWithPin() throws Exception {
+ PinningTrustManager tm = new PinningTrustManager(selfSigned());
+ X509Certificate other = selfSigned();
+
+ assertThrows(CertificateException.class, () -> tm.checkServerTrusted(new X509Certificate[] {other}, "RSA"));
+ assertEquals(other, tm.rejectedCertificate());
+ }
+
+ @Test
+ public void fingerprint_isColonSeparatedSha256() {
+ String fingerprint = PinningTrustManager.fingerprint(selfSigned());
+ assertTrue(fingerprint, fingerprint.matches("([0-9A-F]{2}:){31}[0-9A-F]{2}"));
+ }
+
+ @Test
+ public void pinnedCertificate_survivesSavingInCredentials() {
+ X509Certificate cert = selfSigned();
+ Credentials pinned = Credentials.fromSaved(
+ "alice",
+ "bBthXg9OHtoBhzVQaCvJ-tT6SBNm3k_8d84OEDVjl8E=",
+ "BYVy_Lfm1pO5uSEQRq3UmHDegjhE2RpFt60PVZJjfp4=",
+ "https://example.org",
+ true,
+ "")
+ .withPinnedCertificate(cert);
+
+ Credentials restored = Credentials.fromSaved(
+ pinned.user,
+ pinned.loginHash,
+ pinned.msgHash,
+ pinned.server,
+ pinned.allowSelfSigned,
+ pinned.encodedPinnedCertificate());
+
+ assertEquals(cert, restored.pinnedCertificate);
+ }
+}
diff --git a/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java b/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java
new file mode 100644
index 0000000..ed33b37
--- /dev/null
+++ b/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java
@@ -0,0 +1,25 @@
+package com.data_dive.com.clipster;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class UtilsTest {
+
+ @Test
+ public void formatServerUri_addsHttpsWhenMissing() {
+ assertEquals("https://clipster.cc", Utils.formatServerUri("clipster.cc"));
+ }
+
+ @Test
+ public void formatServerUri_upgradesHttp() {
+ assertEquals("https://example.org:9999", Utils.formatServerUri("http://example.org:9999"));
+ assertEquals("https://example.org", Utils.formatServerUri("HTTP://example.org"));
+ }
+
+ @Test
+ public void formatServerUri_keepsHttpsAndStripsTrailingSlashesAndWhitespace() {
+ assertEquals("https://example.org/sub", Utils.formatServerUri(" https://example.org/sub// "));
+ assertEquals("https://Example.org", Utils.formatServerUri("HTTPS://Example.org/"));
+ }
+}
diff --git a/build.gradle b/build.gradle
index 9e43be8..1ca5f61 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,21 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id 'com.android.application' version '9.4.1' apply false
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.spotless)
}
-tasks.register('clean', Delete) {
- delete rootProject.layout.buildDirectory
+// Check with ./gradlew spotlessCheck, fix with ./gradlew spotlessApply
+spotless {
+ java {
+ target 'app/src/**/*.java'
+ palantirJavaFormat(libs.versions.palantirJavaFormat.get())
+ removeUnusedImports()
+ }
+ format 'misc', {
+ target '*.gradle', '*.md', '.gitignore', 'gradle/*.toml', '.github/**/*.yml', '.github/**/*.md',
+ 'app/src/**/*.xml', 'fastlane/**/*.txt'
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
}
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 0000000..e59335e
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,12 @@
+Clipster is a multi platform cloud clipboard: copy a text or image on your smartphone and paste it on your desktop, or vice versa.
+
+Features
+
+- Share texts and images from any app with the share menu, or share your clipboard
+- Get the last clip with one tap or from the app shortcut, or pick one from your recent clips
+- Clips are encrypted on your device, the server only stores encrypted data
+- Use the public server at clipster.cc or run your own Clipster-Server, also with a self-signed certificate
+- No ads, no analytics, no trackers
+
+
+Clients are available for Android, Linux, macOS, Windows and all browsers. Source code and the desktop client: https://github.com/mc51
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 0000000..14b20ac
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+End-to-end encrypted cloud clipboard for your android phone and desktop
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..0d46c71
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+Clipster
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..91aa1fc
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,29 @@
+[versions]
+agp = "9.4.1"
+appcompat = "1.8.0"
+constraintlayout = "2.2.2"
+core = "1.19.1"
+fernet = "1.5.0"
+jhash = "2.2.0"
+json = "20260814"
+junit = "4.13.2"
+material = "1.14.0"
+okhttp = "5.5.0"
+palantirJavaFormat = "2.100.0"
+spotless = "8.10.3"
+
+[libraries]
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
+androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
+androidx-core = { module = "androidx.core:core", version.ref = "core" }
+fernet = { module = "com.macasaet.fernet:fernet-java8", version.ref = "fernet" }
+jhash = { module = "com.amdelamar:jhash", version.ref = "jhash" }
+junit = { module = "junit:junit", version.ref = "junit" }
+material = { module = "com.google.android.material:material", version.ref = "material" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
+org-json = { module = "org.json:json", version.ref = "json" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }