From d1b99fbca8eec78ddd8b02fcd8681b47a54ac07c Mon Sep 17 00:00:00 2001 From: "MC51 (Michael)" Date: Mon, 28 Sep 2026 22:34:39 +0200 Subject: [PATCH 1/2] feat: major refactor of UI, code quality and bug fixes --- .github/dependabot.yml | 17 + .github/workflows/build.yml | 13 +- .github/workflows/ci.yml | 45 ++ app/build.gradle | 43 +- app/lint-baseline.xml | 73 ++ app/src/main/AndroidManifest.xml | 17 +- .../com/data_dive/com/clipster/Async.java | 45 ++ .../com/clipster/ClipListAdapter.java | 114 ++++ .../data_dive/com/clipster/Credentials.java | 62 +- .../com/data_dive/com/clipster/Crypto.java | 78 +++ .../data_dive/com/clipster/CustomList.java | 62 -- .../clipster/DebouncedOnClickListener.java | 47 -- .../com/clipster/ListClipsActivity.java | 209 +++--- .../data_dive/com/clipster/MainActivity.java | 172 ++--- .../com/data_dive/com/clipster/NetClient.java | 631 ++++++++---------- .../data_dive/com/clipster/ReadyActivity.java | 120 ++-- .../data_dive/com/clipster/ShareActivity.java | 112 ++-- .../com/data_dive/com/clipster/Utils.java | 377 +++++------ .../main/res/drawable/clipster_250_banner.png | Bin 12018 -> 7228 bytes app/src/main/res/drawable/cloud.png | Bin 1801 -> 0 bytes app/src/main/res/drawable/grdnt.xml | 7 - app/src/main/res/drawable/ic_arrow_back.xml | 11 + app/src/main/res/drawable/ic_cloud.xml | 10 + app/src/main/res/drawable/ic_lock.xml | 10 + app/src/main/res/drawable/ic_person.xml | 10 + app/src/main/res/drawable/message.png | Bin 4277 -> 0 bytes app/src/main/res/drawable/open_padlock.png | Bin 1398 -> 0 bytes app/src/main/res/drawable/phonemo.png | Bin 3409 -> 0 bytes app/src/main/res/drawable/pswrd.png | Bin 4095 -> 0 bytes app/src/main/res/drawable/usrusr.png | Bin 3848 -> 0 bytes .../main/res/layout/activity_list_clips.xml | 103 +-- app/src/main/res/layout/activity_main.xml | 232 +++---- app/src/main/res/layout/activity_ready.xml | 209 +++--- app/src/main/res/layout/activity_share.xml | 35 +- app/src/main/res/layout/list_clips_items.xml | 8 - app/src/main/res/layout/list_single_img.xml | 28 +- app/src/main/res/layout/list_single_txt.xml | 22 +- app/src/main/res/values-night/colors.xml | 18 + app/src/main/res/values-v35/styles.xml | 12 - app/src/main/res/values/colors.xml | 19 +- app/src/main/res/values/dimens.xml | 11 +- app/src/main/res/values/strings.xml | 61 +- app/src/main/res/values/styles.xml | 20 - app/src/main/res/values/themes.xml | 33 + app/src/main/res/xml/backup_rules.xml | 5 + .../main/res/xml/data_extraction_rules.xml | 10 + app/src/main/res/xml/shortcuts.xml | 15 + .../data_dive/com/clipster/CryptoTest.java | 88 +++ .../com/data_dive/com/clipster/UtilsTest.java | 57 ++ 49 files changed, 1809 insertions(+), 1462 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 app/lint-baseline.xml create mode 100644 app/src/main/java/com/data_dive/com/clipster/Async.java create mode 100644 app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java create mode 100644 app/src/main/java/com/data_dive/com/clipster/Crypto.java delete mode 100644 app/src/main/java/com/data_dive/com/clipster/CustomList.java delete mode 100644 app/src/main/java/com/data_dive/com/clipster/DebouncedOnClickListener.java delete mode 100644 app/src/main/res/drawable/cloud.png delete mode 100644 app/src/main/res/drawable/grdnt.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_cloud.xml create mode 100644 app/src/main/res/drawable/ic_lock.xml create mode 100644 app/src/main/res/drawable/ic_person.xml delete mode 100644 app/src/main/res/drawable/message.png delete mode 100644 app/src/main/res/drawable/open_padlock.png delete mode 100644 app/src/main/res/drawable/phonemo.png delete mode 100644 app/src/main/res/drawable/pswrd.png delete mode 100644 app/src/main/res/drawable/usrusr.png delete mode 100644 app/src/main/res/layout/list_clips_items.xml create mode 100644 app/src/main/res/values-night/colors.xml delete mode 100644 app/src/main/res/values-v35/styles.xml delete mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/shortcuts.xml create mode 100644 app/src/test/java/com/data_dive/com/clipster/CryptoTest.java create mode 100644 app/src/test/java/com/data_dive/com/clipster/UtilsTest.java 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/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..a734d34 --- /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: Lint, unit tests and release build + run: ./gradlew 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/app/build.gradle b/app/build.gradle index 131a27d..e7ba0b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,24 @@ plugins { id 'com.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 { def keyProps = new Properties() @@ -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" + // 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,30 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + lint { + abortOnError = true + checkDependencies = false + // New issues fail the build, known ones are listed in the baseline. + // Regenerate with: ./gradlew updateLintBaseline + baseline = file('lint-baseline.xml') + } + + 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.google.android.material:material:1.14.0' implementation 'com.macasaet.fernet:fernet-java8:1.5.0' implementation 'com.amdelamar:jhash:2.2.0' + + testImplementation 'junit:junit:4.13.2' + // The Android SDK only ships stubs of org.json for unit tests + testImplementation 'org.json:json:20260814' } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..9c433a0 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e10f5b..d680c2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,14 +10,17 @@ 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 +43,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/ClipListAdapter.java b/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java new file mode 100644 index 0000000..05838b0 --- /dev/null +++ b/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java @@ -0,0 +1,114 @@ +package com.data_dive.com.clipster; + +import android.content.Context; +import android.graphics.Bitmap; +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 org.json.JSONArray; +import org.json.JSONObject; + +import java.util.HashSet; +import java.util.Set; + +/** + * Shows decrypted text and image clips. 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 final LayoutInflater inflater; + private final JSONArray 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, JSONArray clips) { + this.inflater = LayoutInflater.from(context); + this.clips = clips; + this.thumbnailSize = context.getResources().getDisplayMetrics().widthPixels; + } + + @Override + public int getCount() { + return clips.length(); + } + + @Override + public JSONObject getItem(int position) { + return clips.optJSONObject(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public int getItemViewType(int position) { + return Utils.FORMAT_IMG.equals(format(position)) ? TYPE_IMG : TYPE_TXT; + } + + public String text(int position) { + JSONObject clip = getItem(position); + return clip != null ? clip.optString("text_decrypted", "") : ""; + } + + public String format(int position) { + JSONObject clip = getItem(position); + return clip != null ? clip.optString("format", Utils.FORMAT_TXT) : Utils.FORMAT_TXT; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + if (getItemViewType(position) == TYPE_TXT) { + if (view == null) { + view = inflater.inflate(R.layout.list_single_txt, parent, false); + } + ((TextView) view).setText(text(position)); + return view; + } + + if (view == null) { + view = inflater.inflate(R.layout.list_single_img, parent, false); + } + ImageView imageView = view.findViewById(R.id.img); + imageView.setTag(position); + Bitmap cached = thumbnails.get(position); + imageView.setImageBitmap(cached); + if (cached == null && loading.add(position)) { + String b64 = text(position); + Async.run(() -> Utils.B64StringToThumbnail(b64, 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); + } + }); + } + return view; + } +} 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..89e8ccd 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,49 @@ package com.data_dive.com.clipster; -import android.util.Base64; -import android.util.Log; import com.macasaet.fernet.Key; -import java.nio.charset.StandardCharsets; - /** * Define credentials object to pass around functions and activities - * Create PB2KDF hash Key for symmetric encryption + * 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 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 server; public final boolean ignore_cert; - public Credentials(String username, String password, String login_hash, String msg_hash, String server, boolean ignore_cert) { - /** - * Constructor when credentials already saved - */ + private Credentials(String username, String login_hash, String msg_hash, String server, boolean ignore_cert) { this.user = username; - this.pw = password; - - 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; - } - + this.login_pw_hash = login_hash; + this.msg_pw_hash = msg_hash; this.server = server; this.ignore_cert = ignore_cert; + this.encryption_key = new Key(msg_hash); + this.token_b64 = Crypto.authToken(user, login_hash); + } + + /** + * Derive hashes from a password. Slow by design, don't call on the main thread. + */ + public static Credentials fromPassword(String username, String password, String server, boolean ignore_cert) { + return new Credentials(username, + Crypto.deriveKey(username, password, Crypto.ITERS_LOGIN_HASH), + Crypto.deriveKey(username, password, Crypto.ITERS_MSG_HASH), + server, ignore_cert); + } - 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 static Credentials fromSavedHashes(String username, String login_hash, String msg_hash, String server, boolean ignore_cert) { + return new Credentials(username, login_hash, msg_hash, server, ignore_cert); } - private String createAuthToken(String user, String pw) { - /** - * Create b64 encoded String Token for Basic Auth to use for API requests - */ - String token = user + ":" + pw; - 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; - } - return token; + @Override + public String toString() { + return "Credentials{user=" + user + ", server=" + server + ", ignore_cert=" + ignore_cert + "}"; } } 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..43797a3 --- /dev/null +++ b/app/src/main/java/com/data_dive/com/clipster/Crypto.java @@ -0,0 +1,78 @@ +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..e4cceb4 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,147 @@ package com.data_dive.com.clipster; +import androidx.activity.EdgeToEdge; 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 android.widget.Toast; + +import com.google.android.material.appbar.MaterialToolbar; import org.json.JSONArray; -import org.json.JSONException; 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 logtag = "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 pending_image; @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); + JSONArray clips = Clips.getInstance().getData(); + if (clips == null) { + // Clips only live in memory, e.g. lost when Android killed the app in the background + finish(); + return; } - back.setOnClickListener(btnListener); - back.setTag("back"); - - 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); - } + setContentView(R.layout.activity_list_clips); - }); + MaterialToolbar toolbar = findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(v -> finish()); + + ListView list = findViewById(R.id.ListOfClipsView); + list.setEmptyView(findViewById(R.id.empty)); + ClipListAdapter adapter = new ClipListAdapter(this, clips); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, id) -> + openPopupMenu(view, adapter.text(position), adapter.format(position))); } private void openPopupMenu(View view, String clip_text, String clip_format) { - // Initializing the popup menu and giving the reference as current context 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; + boolean isImage = Utils.FORMAT_IMG.equals(clip_format); + popupMenu.getMenuInflater().inflate(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_text, clip_format); + } else if (id == R.id.save_to_file) { + getPermissionAndSaveToGallery(clip_text); } + return true; }); popupMenu.show(); } - private void getPermissionAndSaveBitmapToGallery(Bitmap image) { + private void getPermissionAndSaveToGallery(String image_b64) { // 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(image_b64); } 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); + pending_image = image_b64; + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, WRITE_EXTERNAL_STORAGE_REQUEST); } + } + private void saveToGallery(String image_b64) { + Async.run(() -> Utils.SaveBitmapToGallery(this, Utils.B64StringToImage(image_b64)), (saved, error) -> { + boolean ok = error == null && Boolean.TRUE.equals(saved); + if (!ok) { + Log.e(logtag, "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) { 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 && pending_image != null) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + saveToGallery(pending_image); + } else { + Toast.makeText(this, R.string.error_save_image, Toast.LENGTH_LONG).show(); } + pending_image = 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")) { + if (!Utils.FORMAT_IMG.equals(clip_format)) { + Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("text/plain"); intent.putExtra(Intent.EXTRA_TEXT, clip_text); - } else if (clip_format.equals("img")) { + startChooser(intent); + return; + } + Async.run(() -> Utils.BitmapToTempFileAsUri(this, Utils.B64StringToImage(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(logtag, "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/MainActivity.java b/app/src/main/java/com/data_dive/com/clipster/MainActivity.java index d18ead4..4b58b23 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,109 +1,107 @@ package com.data_dive.com.clipster; +import androidx.activity.EdgeToEdge; 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 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 */ -public class MainActivity extends AppCompatActivity { +public class MainActivity extends AppCompatActivity implements NetClient.Listener { private final static String logtag = "MainActivity"; - private final static int BUTTON_DELAY = 2000; + // 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 String SERVER_URI; - EditText password, user, server; - CheckBox ignore_cert; - TextView register, login; - String usr, pw, srv; + private TextInputLayout user_layout, pw_layout, server_layout; + private TextInputEditText password, user, server; + private MaterialCheckBox ignore_cert; + private MaterialButton register, login; + private LinearProgressIndicator progress; + private int running_tasks = 0; @Override protected void onResume() { super.onResume(); - Log.d(logtag, "on Resume: Check for creds"); checkForCreds(); } @Override protected void onCreate(Bundle savedInstanceState) { - Log.d(logtag, "Oncreate"); + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); - SERVER_URI = getResources().getString(R.string.default_host); + SERVER_URI = getString(R.string.default_host); setContentView(R.layout.activity_main); + user_layout = findViewById(R.id.user_layout); + pw_layout = findViewById(R.id.pw_layout); + server_layout = 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); + progress = findViewById(R.id.progress); - login.setOnClickListener(btnListener); - login.setTag("login"); - register.setOnClickListener(btnListener); - register.setTag("register"); - } + login.setOnClickListener(v -> prepareSetupRequest(true)); + register.setOnClickListener(v -> prepareSetupRequest(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) { + displaySavedCredsAsDefaults(); } - }; + } private void displaySavedCredsAsDefaults() { - // Show saved credentials as default entries - if(Utils.areCredsSaved(this)) { - Log.d(logtag, "Creds saved, setting as defaults"); + // Show saved credentials as default entries. 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"); + ignore_cert.setChecked(creds.ignore_cert); } } private void checkForCreds() { - // Get intent and action -> Edit Creds from ReadyActivity calls this - Intent intent = getIntent(); - String action = intent.getAction(); + // Edit Creds from ReadyActivity: stay here and allow the user to edit credentials + 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 i = new Intent(this, ReadyActivity.class); + if (ACTION_GET_LAST_CLIP.equals(action)) { + i.setAction(ACTION_GET_LAST_CLIP); } + startActivity(i); + 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(); + private void prepareSetupRequest(boolean isLogin) { + // Validate input, then register or login + String usr = text(user); + String pw = text(password); + String srv = text(server).trim(); boolean ignore = ignore_cert.isChecked(); if (srv.isEmpty()) { @@ -111,39 +109,59 @@ private void prepareSetupRequest(String action) { } 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(); + user_layout.setError(usr.isEmpty() ? getString(R.string.error_username_required) : null); + if (pw.isEmpty()) { + pw_layout.setError(getString(R.string.error_password_required)); } 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(); + pw_layout.setError(getResources().getQuantityString(R.plurals.error_password_too_short, + Utils.MIN_PW_LENGTH, Utils.MIN_PW_LENGTH)); } 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(); + pw_layout.setError(null); + } + server_layout.setError(Utils.validateServerURI(srv) ? null : getString(R.string.error_server_invalid)); + if (user_layout.getError() != null || pw_layout.getError() != null || server_layout.getError() != null) { + return; + } + + final String final_srv = srv; + onRequestStarted(); + // Key derivation is deliberately slow, keep it off the main thread + Async.run(() -> Credentials.fromPassword(usr, pw, final_srv, ignore), (creds, error) -> { + onRequestFinished(); + if (error != null) { + Log.e(logtag, "Could not create credentials: " + error.getClass().getSimpleName()); + Toast.makeText(this, R.string.error_key_derivation, Toast.LENGTH_LONG).show(); return; } NetClient client = new NetClient(this, creds); - - if (action.equals("login")) { - Log.d(logtag, "Calling login function"); + if (isLogin) { client.Login(); - } else if (action.equals("register")) { - Log.d(logtag, "Calling register function"); + } else { client.Register(); } - } + }); + } + + private static String text(TextInputEditText field) { + return field.getText() != null ? field.getText().toString() : ""; + } + + @Override + public void onRequestStarted() { + running_tasks++; + updateLoading(); + } + + @Override + public void onRequestFinished() { + running_tasks = Math.max(0, running_tasks - 1); + updateLoading(); + } + + private void updateLoading() { + boolean loading = running_tasks > 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..14e3d06 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 @@ -2,9 +2,10 @@ 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; @@ -14,446 +15,374 @@ import org.json.JSONObject; import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; -import java.util.ArrayList; +import java.security.cert.X509Certificate; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSession; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; -import java.security.cert.X509Certificate; /** - * 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/"; + public interface Listener { + void onRequestStarted(); + void onRequestFinished(); + } - private static Context mContext; - private static Credentials credentials; - private static String SERVER_URI = ""; - private static String clip_clear = ""; - private static String device_name; + private enum RequestType { LOGIN, REGISTER, GET_LAST_CLIP, GET_ALL_CLIPS, SET_CLIP } + + private static final String logtag = "NetClient"; + private static final int TIMEOUT_CONN = 8000; + private static final int TIMEOUT_READ = 30000; + private static final String URI_REGISTER = "/register/"; + private static final String URI_VERIFY = "/verify-user/"; + private static final String URI_CLIP = "/copy-paste/"; + // Captured before disableSSLCertChecks() can replace it, so it can be restored later + private static final HostnameVerifier DEFAULT_HOSTNAME_VERIFIER = HttpsURLConnection.getDefaultHostnameVerifier(); + + private final Context context; + private final Context appContext; + private final Credentials credentials; + private final 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) { + // We already have saved working credentials + this(context, Utils.getCreds(context)); + } + + protected NetClient(Context context, Credentials creds) { + this.context = context; + this.appContext = context.getApplicationContext(); + this.credentials = creds; + this.device_name = getDeviceName(context); + if (credentials.ignore_cert) { disableSSLCertChecks(); } else { enableSSLCertChecks(); } - mContext = context; } - protected NetClient(Context context, Credentials creds) { - // Constructor before we have saved credentials - credentials = creds; - SERVER_URI = credentials.server; - device_name = getDeviceName(context); + private static final class Response { + int code; + String body = ""; + JSONArray clips = new JSONArray(); + // Prepared in the background for GET_LAST_CLIP, as images need to be decoded and stored + ClipData clipData; + String clipPreview; - if(credentials.ignore_cert) { - disableSSLCertChecks(); - } else { - enableSSLCertChecks(); + boolean isOk() { + return code == 200 || code == 201; } - mContext = context; + } + + private interface SettingLookup { + String get(); } private String getDeviceName(Context context) { - /** + /* * Try to get the user defined device name - * Unfortunately there is no definite way: we try the most common + * 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. */ - 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"; + 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"), + }; + for (SettingLookup lookup : lookups) { + try { + String name = lookup.get(); + if (name != null && !name.isEmpty()) { + return name; + } + } catch (RuntimeException e) { + Log.d(logtag, "Device name lookup failed: " + e.getClass().getSimpleName()); } - } catch(Exception e) { - Log.e(logtag,"Couldn't get device name: " + e.toString()); - name = "android"; } - return name; + return "android"; } 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", ""); + execute(RequestType.LOGIN, URI_VERIFY, null); } 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(); - 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); - } - req.execute(request_uri, credentials.token_b64, "register", payload); + execute(RequestType.REGISTER, URI_REGISTER, () -> { + JSONObject payload = new JSONObject(); + payload.put("username", credentials.user); + payload.put("password", credentials.login_pw_hash); + return payload.toString(); + }); } 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", ""); + execute(RequestType.GET_LAST_CLIP, URI_CLIP, 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", ""); + execute(RequestType.GET_ALL_CLIPS, URI_CLIP, 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); + final String clip_format = format != null ? format : Utils.FORMAT_TXT; + execute(RequestType.SET_CLIP, URI_CLIP, () -> { + JSONObject payload = new JSONObject(); + payload.put("text", Crypto.encrypt(credentials.encryption_key, clip)); + payload.put("device", device_name); + payload.put("format", clip_format); + return payload.toString(); + }, Utils.clipPreview(appContext, clip, clip_format)); } - 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 - */ - 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) { - } + private void execute(RequestType type, String path, Async.Work payload) { + execute(type, path, payload, null); + } + + private void execute(RequestType type, String path, Async.Work payload, String preview) { + final String url = credentials.server + path; + notifyStarted(); + Async.run(() -> { + Response response = send(type, url, payload != null ? payload.run() : null); + if (response.isOk() && type != RequestType.SET_CLIP) { + response.clips = parseClips(response.body); + if (type == RequestType.GET_LAST_CLIP && response.clips.length() > 0) { + JSONObject last = response.clips.getJSONObject(response.clips.length() - 1); + String text = last.getString("text_decrypted"); + String format = last.optString("format", Utils.FORMAT_TXT); + response.clipData = Utils.createClipData(appContext, text, format); + response.clipPreview = Utils.clipPreview(appContext, text, format); } - }; - // 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; + } + return response; + }, (response, error) -> { + try { + if (error != null) { + handleError(type, error); + } else { + handleResponse(type, response, preview); } - }); - } catch (GeneralSecurityException e) { - Log.e(logtag, e.toString()); - } + } finally { + notifyFinished(); + } + }); } - public static void enableSSLCertChecks() { - // Re-enable SSL Certification Checks - Log.d(logtag, "Enabling SSL Cert Checks"); - // Install standard trust manager + private Response send(RequestType type, String request_uri, String payload) throws IOException { + Log.d(logtag, type + " " + request_uri); + HttpsURLConnection conn = (HttpsURLConnection) new URL(request_uri).openConnection(); 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()); - } - } - - // Send request to API on server - public static class ClientRequest extends AsyncTask { - - private ArrayList return_values = new ArrayList(); + conn.setConnectTimeout(TIMEOUT_CONN); + conn.setReadTimeout(TIMEOUT_READ); + conn.setRequestProperty("Content-type", "application/json; utf-8"); + conn.setRequestProperty("Accept", "application/json"); + if (type != RequestType.REGISTER) { + conn.setRequestProperty("Authorization", "Basic " + credentials.token_b64); + } + if (payload != null) { + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(payload.getBytes(StandardCharsets.UTF_8)); + } + } else { + conn.setRequestMethod("GET"); + } - private ClientRequest(Context context) { - mContext = context; + Response response = new Response(); + response.code = conn.getResponseCode(); + Log.d(logtag, "Response code: " + response.code); + InputStream stream = response.code < 400 ? conn.getInputStream() : conn.getErrorStream(); + if (stream != null) { + response.body = readAll(stream); + } + return response; + } finally { + conn.disconnect(); } + } - @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"; + private static String readAll(InputStream stream) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); } - Log.d(logtag, request_uri + " " + mode + " " + token + " " + req_type + " " + payload); + } + return sb.toString(); + } + private JSONArray parseClips(String body) { + // The server answers with an array of clips, or a single clip object + JSONArray clips; + try { + clips = new JSONArray(body); + } catch (JSONException e) { + clips = new JSONArray(); 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); - } - - 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); - } - } else if (mode.equals("GET")) { - Log.d(logtag, "GET"); - conn.setRequestMethod("GET"); - try { - conn.connect(); - } catch (Exception e) { - Log.e(logtag, "Get connection: " + e); - } - } - - response_code = conn.getResponseCode(); - Log.d(logtag, "Response: " + response_code); + clips.put(new JSONObject(body)); + } catch (JSONException err) { + Log.e(logtag, "Could not parse response as JSON array or object"); + return clips; + } + } + return Utils.decryptClips(clips, credentials.encryption_key, + appContext.getString(R.string.error_decrypt_clip)); + } - if (response_code >= 200 && response_code <= 399) { - // OK response - r_buffer = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8")); + private void handleResponse(RequestType type, Response response, String preview) { + if (!response.isOk()) { + showError(type, response.code, parseErrorDetail(response.body)); + return; + } + switch (type) { + case LOGIN: + case REGISTER: + Utils.saveCreds(appContext, credentials); + toast(appContext.getString(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 (response.clipData == null) { + toast(appContext.getString(R.string.msg_no_clip_on_server)); } else { - // Error response code - r_buffer = new BufferedReader(new InputStreamReader(conn.getErrorStream(), "UTF-8")); + Utils.setClipboard(appContext, response.clipData, response.clipPreview); } - 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; + break; + case GET_ALL_CLIPS: + Clips.getInstance().setData(response.clips); + startActivity(new Intent(context, ListClipsActivity.class), false); + break; + case SET_CLIP: + toast(appContext.getString(R.string.msg_clip_shared, preview)); + break; } + } + + private void handleError(RequestType type, Exception error) { + Log.e(logtag, type + " failed: " + error); + String reason = error instanceof IOException + ? appContext.getString(R.string.error_connection, credentials.server) + : appContext.getString(R.string.error_unexpected); + toast(appContext.getString(R.string.error_request_failed, actionName(type), reason)); + } - @Override - protected void onPostExecute(ArrayList values) { - Log.d(logtag, "onPostExecute: " + values); - // Deal with responses - handleRequestAnswer(values); + private void showError(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); } + toast(appContext.getString(R.string.error_request_failed, actionName(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 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 String parseErrorDetail(String body) { + // Server errors look like {"detail": "..."}, anything else (e.g. an HTML page) is not shown 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."); + return new JSONObject(body).getString("detail"); + } catch (JSONException e) { + return null; } - 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); - } + } + + 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(); } - } 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 static JSONArray parseJSONOKResponse(String res) { - /** - * Parse OK HTTP Response from JSON to ArrayList - */ - JSONObject clip = null; - JSONArray clips = null; - try { - clips = new JSONArray(res); - clips = Utils.DecryptClips(mContext, clips); - } 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); + private void toast(String msg) { + Toast.makeText(appContext, msg, Toast.LENGTH_LONG).show(); + } - } catch (JSONException err) { - Log.e(logtag, "Error: could not parse Json as Array or Object.\n" + err); - clips = new JSONArray(); - } + private void notifyStarted() { + if (context instanceof Listener) { + ((Listener) context).onRequestStarted(); } - 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 notifyFinished() { + if (context instanceof Listener) { + ((Listener) context).onRequestFinished(); } - 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 + 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 */ - Log.d(logtag, "startReadyActivity"); + Log.d(logtag, "Disabling SSL Cert Checks"); + TrustManager[] trustAllCerts = new TrustManager[] { + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + @SuppressLint("TrustAllX509TrustManager") + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + @SuppressLint("TrustAllX509TrustManager") + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + } + }; 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); + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + } catch (GeneralSecurityException e) { + Log.e(logtag, e.toString()); } } - private static void startListClipsActivity(Context context, JSONArray clips) { - /** - * Start Activity to show all decrypted Clips - */ - Log.d(logtag, "startListClipsActivity"); + public static void enableSSLCertChecks() { + Log.d(logtag, "Enabling SSL Cert Checks"); 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); + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, null, null); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + HttpsURLConnection.setDefaultHostnameVerifier(DEFAULT_HOSTNAME_VERIFIER); + } catch (GeneralSecurityException e) { + Log.e(logtag, "Error enabling SSL Cert: " + e); } } -} \ No newline at end of file +} 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..2ab3e56 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,94 @@ package com.data_dive.com.clipster; +import androidx.activity.EdgeToEdge; 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 com.google.android.material.button.MaterialButton; +import com.google.android.material.progressindicator.LinearProgressIndicator; + /** * We're authenticated - Show main screen * Deal with button clicks -> requests * */ -public class ReadyActivity extends AppCompatActivity { +public class ReadyActivity extends AppCompatActivity implements NetClient.Listener { - 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 get_last_clip, get_all_clips, set_clip, edit_creds; + private LinearProgressIndicator progress; + private int running_requests = 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(); + 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); + progress = findViewById(R.id.progress); 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"); - } - - 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"); - } - } - - 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; - } - NetClient client = new NetClient(this); - client.SetClipOnServer(clip, null); - } else if(action.equals("edit_creds")) { - Log.d(logtag, "Calling edit_creds -> Start Main Activity"); + get_last_clip.setOnClickListener(v -> new NetClient(this).GetLastClipFromServer()); + get_all_clips.setOnClickListener(v -> new NetClient(this).GetAllClipsFromServer()); + set_clip.setOnClickListener(v -> shareClipboard()); + edit_creds.setOnClickListener(v -> { Intent i = new Intent(this, MainActivity.class); i.setAction(Intent.ACTION_EDIT); startActivity(i); finish(); + }); + + if (savedInstanceState == null && MainActivity.ACTION_GET_LAST_CLIP.equals(getIntent().getAction())) { + new NetClient(this).GetLastClipFromServer(); } } -} \ No newline at end of file + + private void shareClipboard() { + String clip = Utils.checkClipboard(this); + if (clip.isEmpty()) { + Toast.makeText(this, R.string.msg_clipboard_empty, Toast.LENGTH_LONG).show(); + return; + } + new NetClient(this).SetClipOnServer(clip, Utils.FORMAT_TXT); + } + + @Override + public void onRequestStarted() { + running_requests++; + updateLoading(); + } + + @Override + public void onRequestFinished() { + running_requests = Math.max(0, running_requests - 1); + updateLoading(); + } + + private void updateLoading() { + boolean loading = running_requests > 0; + progress.setVisibility(loading ? View.VISIBLE : View.INVISIBLE); + get_last_clip.setEnabled(!loading); + get_all_clips.setEnabled(!loading); + set_clip.setEnabled(!loading); + edit_creds.setEnabled(!loading); + } +} 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..45b7995 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,83 @@ package com.data_dive.com.clipster; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.util.Log; import android.widget.Toast; /** - * Add our app to "share with" menu when sharing texts. Alternative to ClipboardWatcherService - * which does only work up to Android 9 + * Add our app to "share with" menu when sharing texts and images. + * Shows a small progress card until the clip is uploaded, then finishes. */ -public class ShareActivity extends AppCompatActivity { - - public final String logtag = this.getClass().getSimpleName(); +public class ShareActivity extends AppCompatActivity implements NetClient.Listener { @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")) { + handleSharedText(intent); + } else if (type.startsWith("image/")) { + handleSharedImage(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(); - } + 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.ImageUriToB64String(this, imageUri), (imageString, error) -> { + if (imageString == null) { + Toast.makeText(this, R.string.error_open_image, Toast.LENGTH_LONG).show(); + finish(); + return; + } + new NetClient(this).SetClipOnServer(imageString, Utils.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(); + if (shared_clip == null || shared_clip.isEmpty()) { + finish(); + return; } - // directly finish so that our activity doesn't show in foreground - finish(); + new NetClient(this).SetClipOnServer(shared_clip, Utils.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() { } - private void ClientGetClip() { - // Send Request for getting clip - NetClient client = new NetClient(this); - client.GetLastClipFromServer(); + @Override + public void onRequestFinished() { + 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..957f754 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 @@ -4,23 +4,22 @@ 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.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; @@ -29,19 +28,20 @@ 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 java.io.IOException; +import java.io.OutputStream; +import java.util.Locale; import static android.content.Context.CLIPBOARD_SERVICE; /** * implement common functions that are used in different Activities / Classes + * Never log credentials, hashes, tokens or clip contents here. */ public class Utils { - private static String logtag = "Utils"; + private static final String logtag = "Utils"; private static final String PREF_FILE = "pref_file"; private static final String PREF_IS_SAVED = "saved_id"; @@ -52,19 +52,16 @@ public class Utils { private static final String PREF_CRED_TOKEN = "cred_token"; private static final String PREF_CRED_IGNORE_CERT = "cred_ignore_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 String FORMAT_TXT = "txt"; + public static final String FORMAT_IMG = "img"; - 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; 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"); SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE); if (!pref.getBoolean(PREF_IS_SAVED, false)) { return false; @@ -73,20 +70,14 @@ 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(logtag, "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); + Log.d(logtag, "saveCreds: " + creds); SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE); SharedPreferences.Editor editor = pref.edit(); editor.putString(PREF_CRED_USER, creds.user); @@ -102,64 +93,59 @@ public static void saveCreds(Context context, Credentials creds) { 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.fromSavedHashes( + 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_IGNORE_CERT, false)); } 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(); + pref.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 clip_text, String clip_format) { + if (FORMAT_IMG.equals(clip_format)) { + return context.getString(R.string.clip_preview_image); + } + if (clip_text.length() > MAX_CLIP_SHOW_LEN) { + return clip_text.substring(0, MAX_CLIP_SHOW_LEN) + " [...]"; + } + return clip_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, String clip_text, String clip_format) { + if (FORMAT_IMG.equals(clip_format)) { + Uri imageUri = BitmapToTempFileAsUri(context, B64StringToImage(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 = ""; ClipboardManager cb = (ClipboardManager) context.getSystemService(CLIPBOARD_SERVICE); ClipData cd = cb.getPrimaryClip(); @@ -169,112 +155,65 @@ public static String checkClipboard(Context context) { if (text != null) { clip = text.toString(); } - Log.d(logtag, "Clipboard checked:\n" + clip); } return clip; } - 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); - } - // 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(); - } - - 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"; - } - 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; } + if (server_uri.startsWith("https://localhost")) { return true; } return android.util.Patterns.WEB_URL.matcher(server_uri).matches(); } 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("/*$", ""); - 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; + String uri = server_uri.trim().replaceFirst("/+$", ""); + String lower = uri.toLowerCase(Locale.ROOT); + if (lower.startsWith("https://")) { + return "https://" + uri.substring("https://".length()); + } + if (lower.startsWith("http://")) { + return "https://" + uri.substring("http://".length()); } + return "https://" + uri; } public static Bitmap B64StringToImage(String imgString) { - // Decode base64 string containing image to Bitmap image and return - Bitmap decodedImage = null; + // Decode base64 string containing image to Bitmap image, null if it's not a valid image 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 + return BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.length); + } catch (IllegalArgumentException e) { + Log.e(logtag, "Error B64StringToImage: " + e); + return 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 B64StringToThumbnail(String imgString, int maxSize) { + try { + byte[] imgBytes = Base64.decode(imgString, Base64.DEFAULT); + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.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(imgBytes, 0, imgBytes.length, opts); + } catch (IllegalArgumentException e) { + Log.e(logtag, "Error B64StringToThumbnail: " + e); + return null; } - return decodedImage; } public static String BitmapToB64String(Bitmap imageBitmap) { @@ -282,92 +221,102 @@ public static String BitmapToB64String(Bitmap imageBitmap) { if (imageBitmap == 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 (!imageBitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)) { + Log.e(logtag, "Error BitmapToB64String: compress failed"); 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(logtag, "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 ImageUriToB64String(Context context, Uri imageUri) { + return BitmapToB64String(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 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(logtag, "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/clipster_250_banner.png b/app/src/main/res/drawable/clipster_250_banner.png index f556f8b2b325f2fe9de00282daa7b6c900a98b7f..c250d5f84cc5d0e9fd924cd71ffa920d04d4f3dc 100644 GIT binary patch literal 7228 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 KMNUMnLSTYc!sV_2 literal 12018 zcmZ{qby!vXm&HK^6loCY77!Isx8u{PH`uuqK${k0Um#xJO>p`PIl5>q`QG-^IygezKyk7C$ zY#A*@u_iP@Dze=nUHe<`PvlAMV3|2y_Fn6ThoJLK!<+bt7ZxS7;rH{#v% z^QYtfqU7)2zb&&SCMU&NlgJZ zk8&-BPbysxVz<6kWN-&X5a_ce(bLkR&iS5FDrE^{rznhW3jVg{Iyg9RTpysN;CH#* z(9m%E&Yk8ohlV@S($dK(DWy7`PM>mf7eqj0h*ef^^3$6l zS7-F}UdoJK;^H?nOAMk9*ZRrRaqix_aXUmjyxjyoZ=a`yf7C3bjqg^0J6fZM6C$2% zS}rdu+x)}xQ02vokjAUyl8n5M()l|SI2g*!jFO!^&!}+e;y&LQ{OcpEd*j$F$FSNF z^^xY+rV5)e7r!Ovns+h`>%;lD1|AD&QB*=?^iol85C}x&B6&n+R#xoRSP4BhH?dZ+ zo|u#r`j;NIWd_MGY`%ZO%3W_}Byla$wc>nnaoEr6b@aK%AMuEo9lQL40TI%N{kIyp(^ zfBkRnspY^&Zo7%&TP`C*Z3A@?pJxmrdVAmUv+R!MI2o}&AeTop2ppzODjvOkiy-91p=vrQFex zk?itvqJ)G5W;QnI7ccIih|yoakdR$jNjx$#^7-r6 zU_|7R<_FRT(yES-E1hKq@LqV zt+}D1CszcRAvf;byZ8Ca7jbwkmPd~&sFD*C6Tg*}1yxjV+1l9|TUd;-W?|6x;6)KN7bdJ$e*H zEkd=kvxD;H{M>80!Iyk+aIo5O;l|EPUD*5g1a@Qrq9_&?7PoHQn$Jx#Xc;dxeQMN% zayz8OEurw+H@wrGndb@$SSBVW?=mxET3W{Lkgf>BLVPoSCH8nLB zB|p*S`Ed}O-%n}QS(8b+;`t&J3rGb|h|Hy1J3&<(v!*3^Z^?K=C~G=25>C7G`U(RAhun&;{h@GlgQ4hr$n}f^#LB zCG0!N$v*P?_k5C}pVjwAX2!+=R>OJCeSJ#0W8K}|)h-(ZtOh^AmzT|mh=@KmG#C^H zfleBC#!z}({aq9AnH(^OdOknjD!KC}=CNTzy0wi>K^;CRsVqUnr|v4n4E_Y?*RP}H zs3|DEs#oU9C#m`R`bJtE9v(*Yukpw}B946c?AiP9a4JC$QQb;gS~fPedj8)`S65f% z-lz5~tgQC&!Wu01^c#FqR(sQ=L7b%;Po3iAa?5Qep8f3U8TRZPL`cJx7W8_Gu!iAK zKKo*8U6K0!{rh$Qt2O`6hK+tje#$B;c-vD|C5pB6{?`pkeo%@o|4!`a=zjM0<`(Hz zsm(R`rd@{mU7o9IXlR5Te$=P;GBBVrF*S9~6c7-gXJ^NQ@)PnavbDFz5zZ>1yn`aj zKtNj2uHK(We^^PV^);!;X46LccR&Q8R=(NK@KXqSd{aD$ zvd^j&@z_IwD&X(Oz@@?x5fL%p1roL zw4ZMZJmM)YEepPMf*8 z`G6&-3NsNr;?B!|r*SDM5x;&Zn7w)hg{s_n_WL%}>#q!f`|a)RAcB(>2M8wOf5%&4 zPD>qP3_!e^>7RfjXA8n6zBl+uM7SmPQT= zg0uyvJNiYG`5X%W?8M<4?WU_$EuxLh&86o2{yj;-oUD_|;&I6AB3-se0`qOc-xA*a;r2oafbmSZXF|m{$Yn80d z>({R_@7x(IrjHf_r$fo_5)-)>_G!ib59iQngpoxT<| zM}*}j2F|DO@Y{%n=Zx`#pcu!iJl-dNh_PijEczI5!iGQLH|%$ie^OzdoF0UKNdBt& ze5Y==Yj(Dwo2T)xPe4Oeb+EYh$-{@)FU!m~)>~(0GQcOze2HOr_%Na)mbzq^G=_}p zEtRm(IAe~;WKHAQV&sQ+?*{AyEe~;g_4O&=zJ2SGDW5x}G^gfG06qN;Xr9Tza<|lF zy$pl6jLdBV?`^J(?XRYtF$*W#Q@wfpKYtFrG-T8MCR%517a8Xar3)=}CP$<~CmI~a z1xPEu&3>%#`ECEpEkj+`Tii4>C^looBNH8wk&&$lkF-*4(<+F;YP5rkV23+tZ{uLc zSSy9FGKsL4K&YcVKSa;__>tyU3P=8n4RD@M9cBfwNZ3X5hlSjC(wkpj%jK$Vy*d2+ z>IawREA{8Du3RlGEvb0#S{ID|jF)OxI~u1Q_$-9n-`gxK*&G)Dd+Yj#z5~AJ#_o?p z9I(K{Md#M{2ak`P!Pd3bS7maJPfgMD@>VMf zq=~4muI}7qXJW#Li;GjtJl-6+%VGZOjRc+5>jwD4z(hNA*FYs{tH*kJdV90pyRLN& zlL{^sIz{dhJZ^J`R-sV*9ns`tt4CsDV*JtjeHjAiRf)fT{VJb*8U3zj z5hj}bbanP~VO(6?AMmiYe|qoDH}mUSKX~u};(%pEv4n)ED)qYR<0}?0f*2(NkthZcfhiHo#Iv|?m=>8 zV&OU`!%ejs{CR zv}$}jyf*^_Dh!N_WEZ-0;1aGqyT2lUeb!o)YYGY8h6+V(Jw7Y(@)*-mEs8y9r%JD z3?_egm@qRl6Paxxs;j7~1{@qXFR!jHf(BRn{Cx(-*QEaSwdLP{2-qBIpK=$)}P|2mTe&JSSk;`C0CM6?CHvAmuAfcf9<$MFrz0Koe|Iz*) z9i`*qO5pm|M^hUVbo0i|o344PaJW@fRrxJ^NUv<3`dz${wTVkhE9CKz09PpB;`NX? z5}bL2Y&@g!RHdDEy;nuw+;pSAp~GHWU$7|6!2W4c&x(wqB3E^NSNNP;@6&~e3e`( z%y#Uz3z1=HfdJx?36*ln%hpKs{OIcP-%`bx!7zD)2e?Eykz3`lN!?~b&JpjDlEMIZ zRPbH4N8Wc0-`U+QA*?GxENpIm8OtrJt*w2@RL~L570YHkxoN>h5W#N#izK5}9d047 z-vzI!s|8+0&kC|XC39N6?{gR{G3-oyqB}_$u0joFlHGkr56;0cZ*Edz;^LU0zuhqB zVZX?A8`v1v$BRpyvAak5I8?FuW2m2UpFH<1U3tdAG4+b_6rKv49asJ<=*4<9&LL0h z5D_>ipc)?6m!}Jhi#dl=5OPaR+TY@EeFzQG;8oEt9qvEsmrXf{S43MnjQABjKVwrUO1A>UY_jCdRb^66aU`&$*+>84T zq3BJvMvKsu51!^!5tjToz!Vo3cZm{z=5K4) z*!o+gK#Bz1ciJY(ETWaleE+#o@HtCZaQZJr^Q5}!b@%mkEO*B{%{K*bW2m6K86Vfq z=;3D|kd2H}yLsb=i_alk8l;~I)~utoOm7^(J&Kux_;mF2`}>{LRa2BF%|RI2r6y=f z8T=!M(LR)dT3ef{lqo3d(D0WL9vRo2 zc?)}cr8I84P1UDM;QXK|SzXVKjV0kw@DA7tgRw7n*-*=Hp1)>QgZ3$2wxO=3*45Dw z3JJ5jzdu&9NJkPLF%QeSu_;PBScbT|y1Kr9kmlr(cX{6=M9a|k1yd264LEsmLqkKK z8yYg9g+5G>qf{*%K|fTIks0uQ7zls>n@TYGWM{_a_bc+)$_04NsT>wKo*o`}D3fIt z%8~nPN9|Fsot>>!dGteYT-y?47?6RYTCbRU=L{ia2QH&8Qz%kCiM1IL1u81)`dA4y z=y8}qy_Xmbo@jvv9UB`i5~0DP*_o~h8X8jTGJBSB*hChcH)0W7M}Bg00!2%JLoJL2 z=Nw$C&(WYXuj4#Qo^n=pYj>Km|POR~Ahus1h1 zkvb23!?-_F$kEBExhswySPISAzkjXG&8UC|<{f#FOu^os74*yT>cSJ~khH}YJ4ne7 z)$_{N(R)^$pE@l{=r#C|07Lq})fk^9$b42G-oG1-VoXn?t0ivRURLV?rYfA)pcmpP8 zit6Np`uh6NOQYaW=OOOGH<*+r1G0iTj(Pm}ad_v_Mg}eFN4N}-Y}LrQxYX1y?%V^s zb$GXL0Sgf0=H@nYa8QO@ZDwbuNEa7==F`x8%BEBPd4hc;Hs|~I_kbP-Hf`QR2f73e z?p-ZXVP1z5;$=Y*y|TRT!9avraqjBqNCTbtdwDrSwu+38@SKsiH-VIki)IH|bZD_& zjktQA!eB(wjDeGzTV6Cd57|BI@!#RIKyTj0#N@&Ihtg6?bHyVd5C=OUl|Ty`{`Kou zeueAR)zu^L4Qj7my(*kU9^z<9fl;H*LCqYV*@yQD37;=@L~jjLqQCKj7$fCky6B(9 z@A@`RAw}Gzw7U1_>b}SF+L|=zI^xFXb3k@hZQ=OnOCO*2Kfvq&rxWORwtzv#>u6Nh^(0Ql^5Sg2+$EbcTStiiSpTXec@uhi#p-o|V9!6>%Co za74sNjoqnPWA{8T0~S^8aXhdjBQM{&&>9AewrRx~$k_buZY*5dX7tbjFSyIlwl<0X z0q6;GQRF;0AnOkMU!A~yq2o}Dx3SfOHo&=hmx9Bco_U79k9U z1RX7il*4>J!+jPIlSP^vIT?LpM|(RhBO^LEA0&78^yC8};UtT8)B5x@{e42hJwYKM zd=ipy;7to)g|^=@H%7Y$GXc7SPyh(g>-jsU8oeLKqq=*irw%W!;7j2V5zYHco%n== zL2wPCjPXbW0j}coihL?HIvN|8K)DukEEyLj((+~9ehjf46dof)92F-FQLnRcc{;}v z;f+%pwH}LTXrKbZ2hXYN=TC9CI$#lPCQp$*7eE{Y^6>UWs=5$!tU>A!JyvNetA|6w z!*TKPH|d6EWmyyC1g7f~!d2AOZ>A`OLh+bcSRlT9!31Ie=?!uK%qQqUb#+}y0}I5m zRX*nBp~FkbC@V_?p#B}lFLwBq!|{KA z%Hg>8pT8(4jIDck)MoRR)Ju3=ay27dYWGov4mNAj-Ds(s-;h)2RppYx4; z9qJvaYYXs_c@&|=lrG74AK$#Gi}-(DGU=zw>{v%ihtI|}?u+uGVv1?~?t80InCc7-OTBuFJa{L6UQ8?}z5FZZtFa~0eQ zKxLu;*#OoehX*fTMr>|c!;c0G5-veEJ~`O}WV`XBKw%;lDXtoP^3lVQECmt9jmb$(U4$C7y^*` zxw2BzfwV76M7dx1-&a{bfvergg%l2pApkGhL8cPBUSKDOqDVQqATF564lzSvlzZ%( zP<{vaoto*nN*aIdhqAo8t2|ZZV1W>z_Zl-EE7s@q*fUymkB5EL(W`UAR#42t zfPRYvlfcviU>J&NEa4yommUV@odT*>fs_%eR67b7*8+?XjhR+bQPU3B#Q^lud*X^I zDq;io2)_47^CP&8hi!PtDLD_JW5JwBOla2VxI6;X`^n4i!C;}Z>CHQ;SDj!=`dGIT zi^pNs?B{yGx9{IuFsOar0CX?=w>|z_U!>TuF$U_5-NYIy!wVEArgI zbd83QG4vrK3@M^@Zrd%Dc2mjE#>p>%C9&kQmI?>B`^Zi}Vo~ zCEMw0d{Bq4o&GKFU_1y`866)VUx8Hl@V9=piV?JMUXMK*PBLtpJIXYWiN6`QqPMlT zKg|&-Lc_(zZb!Pc6on3w{af^`tT-@?lQK7Fq(2`Hg*IsV?_?X$uZ4mKrsL-Chqe>n zR|<-EHSzTkgQC!RvQ>~R2tF5So_@YzSnqWYZnv158@FkbdNAPPCF<)l42Z5?zj$~K ziuMC>6Xeh;bY}8i-9#f-nAuTEQofD4lvQEDBZu4tV4Tq;CnK{2P-SLsUtERdBb>#km?~k>m!1ue{{$@)9C99polKF9sEtPGwz^%gl9Mmq*5Vq{om;>Q-OxH#>>E*HIPo0|G6~Q=F|z0mj~pI?R+T&ooG=hOE5Hclu3MUrOmG$9 z2f(PLH%nyhlM-&hh&3iIu7le|^YTUl+FfE51A|Oi{7Z1K%NrZAFz+k(I<|)9nPu4= zi1s^QIm`SGH4%{nbe|4n6{r9fd8w;QPSJjOdAZRPa8of46Q-n`{#QO;8@Wl$TwEh& zAKVsN&<;*dEuvE+#9r1oKS)SQN>fOiDYqj0dv-PePApZh|JSciHLusWy$3tKyeP!N zDOU_PFf>%ArKLreK8T~2RK_4)m7v+wqEq?2&Hlt4cxGQRP>UL{HIaG3ZW z0>zC*Z9Qno86F)C=`*B{I9-LgE%**0yK&2{peYxqMi^8nS9NY9b&}hDIvhsL-+N8b z5#XGHKoFK-ZVvO6nG;Ir+Hkvj4Sai-_P>EX2l4cU!W;;@AooG7PEiM_`4MBHT&@QM zah(elEiElx099!@&^Gr+vc-Fe-c%EC`2nI4Um z-7)9obF)!ERa;x;`d{OKf=w+vJ)ck~pZIv=q@_TEL|y6WO}3 zE;Jt~Eo9GaJ3e4b3#~*R27UTEpJ5Kk4^5^1WYWgW+`NMt)~50e2sJ0@ zzMPEA`tKG?PO@64nvl>?q;W}gUA7p~fNWz4NUP{%CMFhuo1uTk#!L6!B%-FC z%T_Y2si`5NqEdprASNJi#EmOc4vi0B1t?xQUY;1*#vXE74LA5n>M3mdpyN_SH8qI> zUqNzwK)1mfEL_!b*Jp|P5U+qYzDr05@jl&I05U40pdbNzHdAv}XfRDdA|2!)?Pz^) z`)46c3E;chaYZiPKs(`Bsk{Lp115iMAH{;$Ve7)f+q-pa>^@KnM@L6?mRNW!a5!n{ z>2Jcoq#2G69yxg=jEaeeQ`6Fx4pw?do|%QmsA~}Bz&$~7f3V&5FHr4lZRfYQu^}-X z0nb-bQi43e&chQXN`sQrGa?mRR8k`S>eT~4^2jtkJ*|g~4&~(^Dn4*GfiH)f(g`MQ zesgn@*7M7V^#ve+@d}&!$f17TGPhd;1PkPz69pwD;95)wY%&Z4FqOG|>jrX}EkO>_ z5Nu+>d>t85JqN4eYyMhiI=z0!Jjg`t!@;`kV_?hr1TBq;nQ~Nsg$Heg&8Ty0<@cIO| zZ*BL%Y&{)TsGPDteUN*wJ=d7^A}Yd=rW$B1>%`92f`V+Y;KOYZgeg0U+Q86{b*9p= zab9X`TkbKC$L|obCdrr7pO8_C`2FZnzQf4GGz`ln-9I)IG<2S|w5X`GLq~A$atV~} z4A07cF-4?tIFyZO?<@n=4@_bu3U1o3pm^(Q;=YSTe zBikXK*v!569#CAiqZT(7x3=UVNcDz#*(a_?8(7)ejrz{o?be@CNlHnnLF7&$g9W+= za4j&%c@B2T+;vouwFU!+VxZ1pmL-P}Umq|v@QyB`DM>ma%#CQC*^e-zRRs!?!f*DB z6vo>_a7?;7JHtkd@Bf59M!CJVk6UL+cnd3fy;1D?qI*Cx2BBH6gWGwc)UJTd#l;1) zVNr5!TN;p3&HRkVJxOd1Blj%XS7U*NWbir#!tlTK!*sI=I2dFwqZDw9g6%}b&0nu| z@Ax0CDk5RixWc!1vx)DJM~%@tuCm3mKX!G=1X+xxlq*5)JY--9My4OoWueUZ^T$Up zAwcNYxs^t8@((Pm!uo~n@Ozx1zosBHuuREmH_4fI#{CnHiX|wf%l0H!Jm+^;DYM~@ zAX4Nd2(S-JIlX?35b{1Luj&2xBm(la6?~^uMxOD3;oWl(Bp75xLH|=&JkKf)1!D(OiAVTf zEKvj5^4?T_f=&AD7BJp+dA9ms2$y1yiCd?q(<#@6ZUQm`E`ape!opNc%9(9{57!jA z+nlw;n28p*tL8dj`38x6!zFUu)+ShUc*F}d76H-oIxpXbGBka^;3QC{1~vZFYKQ^3 zj0mni48o5DU7Tvb8R?ZjIRsQ`RaJc0GXWbP0K2GyFo7=^M9u2&1nm1C{R7bg3_xvehFugX6B9Zl<3O%nLU=enJ{EF5qT;qC0S~`_(8JaWR7Tw*AVd5}`jITKE>J8U z`{^c_@E~&`U=hG{DN#dw6gaS^1Y1x@S_o$Z0h@RvypA+b#1)7N7%6})r+4EaY>c~r z{SYL0hh-2z4vuSmlnf6Z&^~%}7uxTP7ut7Wcn`)9Ad>|>pJn=AdBQ{y))8{#CpGd3 z^bPwT$9KbeRltW2L=w`{HrM(F2KIY$pJT`}Bzy#|djg0GMcr zBVjU^ikBKUc_bz=i30IU1O)=SiGgyt)-RItMp<_u zu|NVv+9l-A1W&#`RmDa@K~cUiQv2P`m2{B0Bw4!KP)jQe>>P5B4rWXd5fRAUJhNvh zZbh2VK47a5B;PI!CIA&Y)oJS-q$*+nx(Mr< zMc+E+VCfK6EmCC=;-mqjI>V%bldCY2j^T{?4H*mQYA0Wy6+_HTRWn^_w6hs%dB@L!KWm#Rx=CO3P@p?=u%5g7NwJUqdS5AgC=>{5 zHc=vGO;_ig&3itq8ew;0^n_joltTVd*e^kmzA5s8lUk^BP6lbAC<={HedZvhhH};% z=bn>Ri;~QyHztDA;9&@kt*nWo2Pb^$S`!8K&e7t=YZrBnUBv2fkk5%qV-A8r)fjf` znib0OWh@TP+Ty6?d#7`f?*E$|64&*n`9<#dr{R}TAZAnVwCljd$AH$=onD)q=(ER~ zmT_-g+r(hlR?@+C&fW(N+cr97?vhpldVW{Y5>bTT!^ z8Mu7a>x;P4m8((4aSFEGmG0MMi%bq=rJq~5F!f%`s_W`v{ldLIaYHRKdxb!Y>Pw#A z?o9RjNdFxPDhX^;P7GcTN<4M!U4?N`tRDIKZn-;hS1<475wCOB zZQ~d9@&jdsUZr6(8-WdC=*TPs-D@u;Mo+jL)U*>=;0=XtOH z&>ewQ)AWvn>SEq;M&0By(2bsbW2gN62gvp4!x>|+&^-v=&;iEZ-fuhbrisn;=z(-% zfbl%QR~u0WI5clPhGoFU9sb}4v13WDK#civJp4V4v05CzacAX<@9QjSezk3KoCUD- zR>e4Np>X)5tMpONsExZ;-ii!cLGtbGmH9cBw<<{wST`?aV!A`nj1Z#N$I?(T*rCF+ zzN0pHSJAbVnTGCI9x)4nQV*!wN9zQyK>1Fu2;@Qgj3C+MWs)B717=JfVG~2s+$*T&)p?XO>!GW zWYNPRKLekNZiFsU6pkLZaT{xnAa}(oom_tp4=B9J!s|hO=b2>_>Yp+iQ~Bnfo;#Pk zje+!*j@0Y5-}i^@8mL=Sso{Kg{0Wn6Eg;Nyk{^CJu{+GQx~&%e zQYN-8=r%JjsXw$Nr8b@XZ3IGiqR_5FFeZ@Q0iQ*U13J{YOb=cYj7mVaC4lQnhK!_o z94~M6DgViqPf33Svo~?61IsAwoR3!F!_0mY(W7aK`~c8Dm9Lab*gE>;PS|WXj8Y38 z$B<17vh4KPEb~m(i~|2Gh-m(2131bS*gx3A6#@0xLycM;U;e=aZ z)quS)>}tw>?$@*mnEjW*t8nol=P-|AKwQGV6OMYSWWF#SFX`a8hGi;_i?j5Ayd8=r z9hil^6u1cJ!TrRp$XIgexB0mNUsnycuR=|uf~7&nM#tQT*ri?Dk7jaZPJ}0p%D*Te zgrV~ZlLY5-K$^P9ieU~SF6*zma)Vlzut|*#kd7m+hGlWvO2UN#QqxS!$Wb+{CW@LN zhuDuU`{_0yRyP4wn%ft|J)s>uSTDxnv$<%m$k6Khyt+syc@3WrU@yz7r$>pbhH5Kr zF_j{>6*1Eiv*jgbz2NGzN2_(K=Ge1OYvB()vW3yqzWYLtfx5_Mgd0{DX9Mjl&vF-3 z1Sx+qQujW@)fg_!TPz0)mNV1JD9anu#I|kZhL6Jv{5hrL-xrqwxizY^2Y4fNCL(rnednAv)O0Ory2l;tckI|2pl_opE@?@kW?ps9=5tej&D#3^IH-O3T3w}y+>lvK)BftBTEe3 Zl~u8ni#>%g5=ovGz|9Gdt#b&={vR$+JnaAg 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 aa940dba3cc41599e0aeac77cacd84a8abaacd80..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4277 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 XH=gCht75Z!00000NkvXXu0mjf5qQzu 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 7ee8c01ad6ea932d61cca44c35c8ddb32e2a208a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1398 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 diff --git a/app/src/main/res/drawable/usrusr.png b/app/src/main/res/drawable/usrusr.png deleted file mode 100644 index 556d2bf5f3ac29a882021886a0b36d4f500768a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3848 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 + + 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..3bfdb22 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,171 +1,121 @@ - - - + android:layout_height="wrap_content"> + - + + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + + - + + - - - + android:layout_marginTop="@dimen/spacing_medium" + android:indeterminate="true" + android:visibility="invisible" /> + + + + - - - - - - - - - \ 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..5662c39 100644 --- a/app/src/main/res/layout/activity_ready.xml +++ b/app/src/main/res/layout/activity_ready.xml @@ -1,116 +1,115 @@ - - - + 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..43c2c4a 100644 --- a/app/src/main/res/layout/activity_share.xml +++ b/app/src/main/res/layout/activity_share.xml @@ -1,9 +1,34 @@ - + android:layout_height="match_parent"> - \ 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..b4ecab0 100644 --- a/app/src/main/res/layout/list_single_img.xml +++ b/app/src/main/res/layout/list_single_img.xml @@ -1,16 +1,16 @@ - - - - - \ No newline at end of file + android:layout_height="wrap_content" + android:minHeight="48dp" + android:padding="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..a193819 100644 --- a/app/src/main/res/layout/list_single_txt.xml +++ b/app/src/main/res/layout/list_single_txt.xml @@ -1,15 +1,11 @@ - - - - - \ No newline at end of file + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="8" + android:minHeight="48dp" + android:paddingHorizontal="@dimen/spacing_medium" + android:paddingVertical="12dp" + android:textAppearance="?attr/textAppearanceBodyLarge" /> 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..e662fdc 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..deb46ce 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/strings.xml b/app/src/main/res/values/strings.xml index cab7f65..6abc7e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,26 +1,65 @@ Clipster + https://clipster.cc + + Login Register - https://clipster.cc + Username + Password + Server + Leave empty to use the public server Disable SSL certificate check (only use with own server) - clipster + 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 - 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 + Share Clip + Shared image + + + 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 + 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/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/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/CryptoTest.java b/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java new file mode 100644 index 0000000..3f65ae8 --- /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 org.junit.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +/** + * 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.login_pw_hash); + assertEquals(MSG_HASH, fromPw.msg_pw_hash); + assertEquals(Crypto.authToken(USER, LOGIN_HASH), fromPw.token_b64); + } +} 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..de446b8 --- /dev/null +++ b/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java @@ -0,0 +1,57 @@ +package com.data_dive.com.clipster; + +import static org.junit.Assert.assertEquals; + +import com.macasaet.fernet.Key; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class UtilsTest { + + private static final Key KEY = new Key("BYVy_Lfm1pO5uSEQRq3UmHDegjhE2RpFt60PVZJjfp4="); + + @Test + public void formatURIProtocol_addsHttpsWhenMissing() { + assertEquals("https://clipster.cc", Utils.formatURIProtocol("clipster.cc")); + } + + @Test + public void formatURIProtocol_upgradesHttp() { + assertEquals("https://example.org:9999", Utils.formatURIProtocol("http://example.org:9999")); + assertEquals("https://example.org", Utils.formatURIProtocol("HTTP://example.org")); + } + + @Test + public void formatURIProtocol_keepsHttpsAndStripsTrailingSlashesAndWhitespace() { + assertEquals("https://example.org/sub", Utils.formatURIProtocol(" https://example.org/sub// ")); + assertEquals("https://Example.org", Utils.formatURIProtocol("HTTPS://Example.org/")); + } + + @Test + public void decryptClips_addsCleartext() throws Exception { + JSONArray clips = new JSONArray() + .put(new JSONObject().put("text", Crypto.encrypt(KEY, "first")).put("format", "txt")) + .put(new JSONObject().put("text", Crypto.encrypt(KEY, "second")).put("format", "txt")); + + Utils.decryptClips(clips, KEY, "error"); + + assertEquals("first", clips.getJSONObject(0).getString("text_decrypted")); + assertEquals("second", clips.getJSONObject(1).getString("text_decrypted")); + } + + @Test + public void decryptClips_badClipDoesNotHideOthers() throws Exception { + JSONArray clips = new JSONArray() + .put(new JSONObject().put("text", "garbage").put("format", "img")) + .put(new JSONObject().put("text", Crypto.encrypt(KEY, "ok")).put("format", "txt")); + + Utils.decryptClips(clips, KEY, "error"); + + assertEquals("error", clips.getJSONObject(0).getString("text_decrypted")); + // Shown as text, not handed to the image decoder + assertEquals("txt", clips.getJSONObject(0).getString("format")); + assertEquals("ok", clips.getJSONObject(1).getString("text_decrypted")); + } +} From c2234bdeb84ff116992743dde43864bfcb2680d4 Mon Sep 17 00:00:00 2001 From: "MC51 (Michael)" Date: Mon, 28 Sep 2026 23:18:30 +0200 Subject: [PATCH 2/2] feat: rewrite networking; lint; docs --- .github/ISSUE_TEMPLATE/bug_report.md | 22 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 9 + .github/pull_request_template.md | 6 + .github/workflows/ci.yml | 4 +- .gitignore | 123 +---- CONTRIBUTING.md | 44 ++ PRIVACY.md | 21 + README.md | 59 ++- SECURITY.md | 17 + app/.gitignore | 1 - app/build.gradle | 32 +- app/lint-baseline.xml | 73 --- app/src/main/AndroidManifest.xml | 3 +- .../com/data_dive/com/clipster/Async.java | 1 - .../java/com/data_dive/com/clipster/Clip.java | 87 ++++ .../com/clipster/ClipListAdapter.java | 117 +++-- .../com/data_dive/com/clipster/Clips.java | 32 +- .../data_dive/com/clipster/Credentials.java | 103 +++- .../com/data_dive/com/clipster/Crypto.java | 1 - .../com/clipster/ListClipsActivity.java | 99 ++-- .../data_dive/com/clipster/LocalNetwork.java | 64 +++ .../data_dive/com/clipster/MainActivity.java | 169 ++++--- .../com/data_dive/com/clipster/NetClient.java | 477 ++++++++++-------- .../com/clipster/PinningTrustManager.java | 146 ++++++ .../data_dive/com/clipster/ReadyActivity.java | 81 +-- .../data_dive/com/clipster/ShareActivity.java | 45 +- .../com/data_dive/com/clipster/Utils.java | 206 ++++---- .../clipster_250_banner.png | Bin .../main/res/layout/activity_list_clips.xml | 2 +- app/src/main/res/layout/activity_main.xml | 9 +- app/src/main/res/layout/activity_ready.xml | 5 +- app/src/main/res/layout/activity_share.xml | 8 +- app/src/main/res/layout/list_single_img.xml | 22 +- app/src/main/res/layout/list_single_txt.xml | 28 +- app/src/main/res/menu/popup_menu_image.xml | 2 +- app/src/main/res/menu/popup_menu_text.xml | 2 +- .../ic_launcher.xml | 8 +- .../ic_launcher_round.xml | 7 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 2210 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 4013 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 1470 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 2511 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 3043 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 5786 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 4795 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 9216 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 6766 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 13339 -> 0 bytes app/src/main/res/values/colors.xml | 38 +- app/src/main/res/values/dimens.xml | 18 +- .../res/values/ic_launcher_background.xml | 2 +- app/src/main/res/values/strings.xml | 146 +++--- app/src/main/res/xml/file_paths.xml | 2 +- .../main/res/xml/network_security_config.xml | 6 - .../com/data_dive/com/clipster/ClipTest.java | 84 +++ .../data_dive/com/clipster/CryptoTest.java | 22 +- .../com/clipster/LocalNetworkTest.java | 37 ++ .../com/clipster/PinningTrustManagerTest.java | 78 +++ .../com/data_dive/com/clipster/UtilsTest.java | 48 +- build.gradle | 18 +- .../android/en-US/full_description.txt | 12 + .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + gradle/libs.versions.toml | 29 ++ 65 files changed, 1688 insertions(+), 994 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md create mode 100644 PRIVACY.md create mode 100644 SECURITY.md delete mode 100644 app/.gitignore delete mode 100644 app/lint-baseline.xml create mode 100644 app/src/main/java/com/data_dive/com/clipster/Clip.java create mode 100644 app/src/main/java/com/data_dive/com/clipster/LocalNetwork.java create mode 100644 app/src/main/java/com/data_dive/com/clipster/PinningTrustManager.java rename app/src/main/res/{drawable => drawable-nodpi}/clipster_250_banner.png (100%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml (56%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml (71%) delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/com/data_dive/com/clipster/ClipTest.java create mode 100644 app/src/test/java/com/data_dive/com/clipster/LocalNetworkTest.java create mode 100644 app/src/test/java/com/data_dive/com/clipster/PinningTrustManagerTest.java create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100644 gradle/libs.versions.toml 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/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/ci.yml b/.github/workflows/ci.yml index a734d34..24d77ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,8 @@ jobs: # Unsigned release build: runs R8 like a real release, but needs no signing # secrets, so this also works for pull requests from forks - - name: Lint, unit tests and release build - run: ./gradlew lint testDebugUnitTest assembleRelease + - name: Formatting, lint, unit tests and release build + run: ./gradlew spotlessCheck lint testDebugUnitTest assembleRelease - name: Upload reports if: always() 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 -[![GitHub Actions Build Workflow](https://github.com/mc51/Clipster-Android/workflows/Build/badge.svg)](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). - -![Clipster demo](resources/demo_01.gif) +[![CI](https://github.com/mc51/Clipster-Android/actions/workflows/ci.yml/badge.svg)](https://github.com/mc51/Clipster-Android/actions/workflows/ci.yml) +[![Release](https://github.com/mc51/Clipster-Android/actions/workflows/build.yml/badge.svg)](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). + +![Clipster demo](resources/demo_01.gif) ## 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 e7ba0b8..8ded57c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'com.android.application' + alias(libs.plugins.android.application) } // The git tag (vMAJOR.MINOR.PATCH) is the only place the version is maintained. @@ -48,7 +48,7 @@ android { // Also hardcoded in res/xml/shortcuts.xml applicationId = "com.data_dive.com.clipster" minSdk = 26 - targetSdk = 36 + 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" @@ -74,10 +74,9 @@ android { lint { abortOnError = true - checkDependencies = false - // New issues fail the build, known ones are listed in the baseline. - // Regenerate with: ./gradlew updateLintBaseline - baseline = file('lint-baseline.xml') + warningsAsErrors = true + // Only reported: Dependabot takes care of updates, new SDK versions need testing first + informational += ['GradleDependency', 'AndroidGradlePluginVersion', 'NewerVersionAvailable', 'OldTargetApi'] } testOptions { @@ -88,14 +87,17 @@ android { } dependencies { - implementation 'androidx.appcompat:appcompat:1.8.0' - implementation 'androidx.core:core:1.19.1' - implementation 'androidx.constraintlayout:constraintlayout:2.2.2' - implementation 'com.google.android.material:material:1.14.0' - implementation 'com.macasaet.fernet:fernet-java8:1.5.0' - implementation 'com.amdelamar:jhash:2.2.0' - - testImplementation 'junit:junit:4.13.2' + 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 'org.json:json:20260814' + testImplementation libs.org.json + // Generates certificates for the TLS trust tests + testImplementation libs.okhttp.tls } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml deleted file mode 100644 index 9c433a0..0000000 --- a/app/lint-baseline.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d680c2a..f1b9cf4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + @@ -17,7 +19,6 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Clipster"> diff --git a/app/src/main/java/com/data_dive/com/clipster/Async.java b/app/src/main/java/com/data_dive/com/clipster/Async.java index 6b33815..9a3fc78 100644 --- a/app/src/main/java/com/data_dive/com/clipster/Async.java +++ b/app/src/main/java/com/data_dive/com/clipster/Async.java @@ -2,7 +2,6 @@ import android.os.Handler; import android.os.Looper; - import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; 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 index 05838b0..49f3f62 100644 --- a/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java +++ b/app/src/main/java/com/data_dive/com/clipster/ClipListAdapter.java @@ -2,6 +2,7 @@ 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; @@ -9,24 +10,34 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; - -import org.json.JSONArray; -import org.json.JSONObject; - import java.util.HashSet; +import java.util.List; import java.util.Set; /** - * Shows decrypted text and image clips. Rows are recycled and images are decoded - * in the background as downsampled thumbnails, so scrolling stays smooth. + * 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 JSONArray clips; + private final List clips; private final int thumbnailSize; private final Set loading = new HashSet<>(); private final LruCache thumbnails = @@ -37,7 +48,8 @@ protected int sizeOf(Integer key, Bitmap value) { } }; - public ClipListAdapter(Context context, JSONArray clips) { + public ClipListAdapter(Context context, List clips) { + this.context = context; this.inflater = LayoutInflater.from(context); this.clips = clips; this.thumbnailSize = context.getResources().getDisplayMetrics().widthPixels; @@ -45,12 +57,12 @@ public ClipListAdapter(Context context, JSONArray clips) { @Override public int getCount() { - return clips.length(); + return clips.size(); } @Override - public JSONObject getItem(int position) { - return clips.optJSONObject(position); + public Clip getItem(int position) { + return clips.get(position); } @Override @@ -65,50 +77,63 @@ public int getViewTypeCount() { @Override public int getItemViewType(int position) { - return Utils.FORMAT_IMG.equals(format(position)) ? TYPE_IMG : TYPE_TXT; - } - - public String text(int position) { - JSONObject clip = getItem(position); - return clip != null ? clip.optString("text_decrypted", "") : ""; - } - - public String format(int position) { - JSONObject clip = getItem(position); - return clip != null ? clip.optString("format", Utils.FORMAT_TXT) : Utils.FORMAT_TXT; + return getItem(position).isImage() ? TYPE_IMG : TYPE_TXT; } @Override - public View getView(int position, View view, ViewGroup parent) { - if (getItemViewType(position) == TYPE_TXT) { - if (view == null) { - view = inflater.inflate(R.layout.list_single_txt, parent, false); - } - ((TextView) view).setText(text(position)); - return view; + 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; + } - if (view == null) { - view = inflater.inflate(R.layout.list_single_img, parent, false); + 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(); } - ImageView imageView = view.findViewById(R.id.img); + 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)) { - String b64 = text(position); - Async.run(() -> Utils.B64StringToThumbnail(b64, 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); - } - }); + if (cached != null || !loading.add(position)) { + return; } - return view; + 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 89e8ccd..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,49 +1,104 @@ package com.data_dive.com.clipster; import com.macasaet.fernet.Key; +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 - * Holds the PBKDF2 derived hashes, the password itself is never stored + * Credentials and server settings. Holds the PBKDF2 derived hashes, the password itself is never stored. */ - public final class Credentials { - public final Key encryption_key; - public final String user; - 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; - private Credentials(String username, String login_hash, String msg_hash, String server, boolean ignore_cert) { - this.user = username; - this.login_pw_hash = login_hash; - this.msg_pw_hash = msg_hash; + 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.ignore_cert = ignore_cert; - this.encryption_key = new Key(msg_hash); - this.token_b64 = Crypto.authToken(user, login_hash); + this.allowSelfSigned = allowSelfSigned; + this.pinnedCertificate = pinnedCertificate; + this.encryptionKey = new Key(msgHash); + this.authToken = Crypto.authToken(user, loginHash); } /** * Derive hashes from a password. Slow by design, don't call on the main thread. */ - public static Credentials fromPassword(String username, String password, String server, boolean ignore_cert) { - return new Credentials(username, - Crypto.deriveKey(username, password, Crypto.ITERS_LOGIN_HASH), - Crypto.deriveKey(username, password, Crypto.ITERS_MSG_HASH), - server, ignore_cert); + 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); + } + + /** + * @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)); + } + + public Credentials withPinnedCertificate(X509Certificate certificate) { + return new Credentials(user, loginHash, msgHash, server, allowSelfSigned, certificate); + } + + /** 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); + } } - public static Credentials fromSavedHashes(String username, String login_hash, String msg_hash, String server, boolean ignore_cert) { - return new Credentials(username, login_hash, msg_hash, server, ignore_cert); + private static X509Certificate decodeCertificate(String encoded) { + if (encoded == null || encoded.isEmpty()) { + return null; + } + try { + 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); + } } @Override public String toString() { - return "Credentials{user=" + user + ", server=" + server + ", ignore_cert=" + ignore_cert + "}"; + 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 index 43797a3..52bff69 100644 --- a/app/src/main/java/com/data_dive/com/clipster/Crypto.java +++ b/app/src/main/java/com/data_dive/com/clipster/Crypto.java @@ -5,7 +5,6 @@ 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; 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 e4cceb4..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,11 +1,5 @@ package com.data_dive.com.clipster; -import androidx.activity.EdgeToEdge; -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; @@ -16,113 +10,124 @@ import android.view.View; import android.widget.ListView; 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 org.json.JSONArray; - +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; public class ListClipsActivity extends AppCompatActivity { - private static final String logtag = "ListClipsActivity"; + private static final String TAG = "ListClipsActivity"; private static final int WRITE_EXTERNAL_STORAGE_REQUEST = 123; // Image waiting for the storage permission (only needed up to Android 9) - private String pending_image; + private String pendingImage; @Override protected void onCreate(Bundle savedInstanceState) { EdgeToEdge.enable(this); super.onCreate(savedInstanceState); - JSONArray clips = Clips.getInstance().getData(); - if (clips == null) { + 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); setContentView(R.layout.activity_list_clips); MaterialToolbar toolbar = findViewById(R.id.toolbar); toolbar.setNavigationOnClickListener(v -> finish()); - ListView list = findViewById(R.id.ListOfClipsView); + 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.text(position), adapter.format(position))); + list.setOnItemClickListener((parent, view, position, id) -> openPopupMenu(view, adapter.getItem(position))); } - private void openPopupMenu(View view, String clip_text, String clip_format) { + private void openPopupMenu(View view, Clip clip) { PopupMenu popupMenu = new PopupMenu(this, view); - boolean isImage = Utils.FORMAT_IMG.equals(clip_format); - popupMenu.getMenuInflater().inflate(isImage ? R.menu.popup_menu_image : R.menu.popup_menu_text, - popupMenu.getMenu()); + 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)); + Utils.setClipboard( + this, + ClipData.newPlainText("Clipster", clip.text), + Utils.clipPreview(this, clip.text, clip.format)); } else if (id == R.id.share) { - openShareMenu(clip_text, clip_format); + openShareMenu(clip); } else if (id == R.id.save_to_file) { - getPermissionAndSaveToGallery(clip_text); + saveToGalleryWithPermission(clip.text); } return true; }); popupMenu.show(); } - private void getPermissionAndSaveToGallery(String image_b64) { + private void saveToGalleryWithPermission(String imageB64) { // MediaStore needs no storage permission from Android 10 on if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED) { - saveToGallery(image_b64); + == PackageManager.PERMISSION_GRANTED) { + saveToGallery(imageB64); } else { - pending_image = image_b64; - ActivityCompat.requestPermissions(this, - 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 image_b64) { - Async.run(() -> Utils.SaveBitmapToGallery(this, Utils.B64StringToImage(image_b64)), (saved, error) -> { + 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(logtag, "Could not save image: " + error); + 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(); + 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); - if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST && pending_image != null) { + if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST && pendingImage != null) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - saveToGallery(pending_image); + saveToGallery(pendingImage); } else { - Toast.makeText(this, R.string.error_save_image, Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.error_save_image, Toast.LENGTH_LONG) + .show(); } - pending_image = null; + pendingImage = null; } } - private void openShareMenu(String clip_text, String clip_format) { - if (!Utils.FORMAT_IMG.equals(clip_format)) { + 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); + intent.putExtra(Intent.EXTRA_TEXT, clip.text); startChooser(intent); return; } - Async.run(() -> Utils.BitmapToTempFileAsUri(this, Utils.B64StringToImage(clip_text)), (imageUri, error) -> { + 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(); + Toast.makeText(this, R.string.error_open_image, Toast.LENGTH_LONG) + .show(); return; } Intent intent = new Intent(Intent.ACTION_SEND); @@ -141,7 +146,7 @@ private void startChooser(Intent intent) { try { startActivity(Intent.createChooser(intent, getString(R.string.share_chooser_title))); } catch (android.content.ActivityNotFoundException e) { - Log.e(logtag, "No app to share with: " + e); + Log.e(TAG, "No app to share with: " + e); } } } 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 4b58b23..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,14 +1,13 @@ package com.data_dive.com.clipster; -import androidx.activity.EdgeToEdge; -import androidx.appcompat.app.AppCompatActivity; - import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.View; 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; @@ -16,79 +15,89 @@ 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 implements NetClient.Listener { - private final static String logtag = "MainActivity"; + 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 String SERVER_URI; - private TextInputLayout user_layout, pw_layout, server_layout; - private TextInputEditText password, user, server; - private MaterialCheckBox ignore_cert; - private MaterialButton register, login; + private TextInputLayout userLayout, passwordLayout, serverLayout; + private TextInputEditText user, password, server; + private MaterialCheckBox allowSelfSigned; + private MaterialButton login, register; private LinearProgressIndicator progress; - private int running_tasks = 0; + private int runningTasks = 0; - @Override - protected void onResume() { - super.onResume(); - checkForCreds(); + // 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) { EdgeToEdge.enable(this); super.onCreate(savedInstanceState); - - SERVER_URI = getString(R.string.default_host); - setContentView(R.layout.activity_main); - user_layout = findViewById(R.id.user_layout); - pw_layout = findViewById(R.id.pw_layout); - server_layout = findViewById(R.id.server_layout); + 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(v -> prepareSetupRequest(true)); - register.setOnClickListener(v -> prepareSetupRequest(false)); + login.setOnClickListener(v -> startSetup(true)); + register.setOnClickListener(v -> startSetup(false)); if (Intent.ACTION_EDIT.equals(getIntent().getAction()) && savedInstanceState == null) { - displaySavedCredsAsDefaults(); + showSavedCreds(); } } - private void displaySavedCredsAsDefaults() { - // Show saved credentials as default entries. The password is never saved. + @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); server.setText(creds.server); - ignore_cert.setChecked(creds.ignore_cert); + allowSelfSigned.setChecked(creds.allowSelfSigned); } } - private void checkForCreds() { - // Edit Creds from ReadyActivity: stay here and allow the user to edit credentials + 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)) { return; } if (Utils.areCredsSaved(this)) { - Intent i = new Intent(this, ReadyActivity.class); + Intent intent = new Intent(this, ReadyActivity.class); if (ACTION_GET_LAST_CLIP.equals(action)) { - i.setAction(ACTION_GET_LAST_CLIP); + intent.setAction(ACTION_GET_LAST_CLIP); } - startActivity(i); + startActivity(intent); finish(); } else if (ACTION_GET_LAST_CLIP.equals(action)) { Toast.makeText(this, R.string.msg_login_first, Toast.LENGTH_LONG).show(); @@ -97,49 +106,71 @@ private void checkForCreds() { } } - private void prepareSetupRequest(boolean isLogin) { - // Validate input, then register or login + private void startSetup(boolean isLogin) { String usr = text(user); String pw = text(password); String srv = text(server).trim(); - boolean ignore = ignore_cert.isChecked(); + boolean selfSigned = allowSelfSigned.isChecked(); + srv = Utils.formatServerUri(srv.isEmpty() ? getString(R.string.default_host) : srv); - if (srv.isEmpty()) { - srv = SERVER_URI; - } - srv = Utils.formatURIProtocol(srv); - - user_layout.setError(usr.isEmpty() ? getString(R.string.error_username_required) : null); + userLayout.setError(usr.isEmpty() ? getString(R.string.error_username_required) : null); if (pw.isEmpty()) { - pw_layout.setError(getString(R.string.error_password_required)); + passwordLayout.setError(getString(R.string.error_password_required)); } else if (pw.length() < Utils.MIN_PW_LENGTH) { - pw_layout.setError(getResources().getQuantityString(R.plurals.error_password_too_short, - Utils.MIN_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 { - pw_layout.setError(null); + passwordLayout.setError(null); } - server_layout.setError(Utils.validateServerURI(srv) ? null : getString(R.string.error_server_invalid)); - if (user_layout.getError() != null || pw_layout.getError() != null || server_layout.getError() != null) { + serverLayout.setError(Utils.isValidServerUri(srv) ? null : getString(R.string.error_server_invalid)); + if (userLayout.getError() != null || passwordLayout.getError() != null || serverLayout.getError() != null) { return; } - final String final_srv = srv; + final String serverUri = srv; onRequestStarted(); - // Key derivation is deliberately slow, keep it off the main thread - Async.run(() -> Credentials.fromPassword(usr, pw, final_srv, ignore), (creds, error) -> { - onRequestFinished(); - if (error != null) { - Log.e(logtag, "Could not create credentials: " + error.getClass().getSimpleName()); - Toast.makeText(this, R.string.error_key_derivation, Toast.LENGTH_LONG).show(); - return; - } - NetClient client = new NetClient(this, creds); - if (isLogin) { - client.Login(); - } else { - client.Register(); - } - }); + // 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 { + client.register(); + } } private static String text(TextInputEditText field) { @@ -148,18 +179,18 @@ private static String text(TextInputEditText field) { @Override public void onRequestStarted() { - running_tasks++; + runningTasks++; updateLoading(); } @Override public void onRequestFinished() { - running_tasks = Math.max(0, running_tasks - 1); + runningTasks = Math.max(0, runningTasks - 1); updateLoading(); } private void updateLoading() { - boolean loading = running_tasks > 0; + 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 14e3d06..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,6 +1,5 @@ package com.data_dive.com.clipster; -import android.annotation.SuppressLint; import android.app.Activity; import android.content.ClipData; import android.content.ContentResolver; @@ -9,91 +8,162 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.URL; -import java.nio.charset.StandardCharsets; +import java.net.URI; import java.security.GeneralSecurityException; import java.security.cert.X509Certificate; - -import javax.net.ssl.HostnameVerifier; +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.SSLException; +import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.json.JSONException; +import org.json.JSONObject; /** * 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 { public interface Listener { void onRequestStarted(); + void onRequestFinished(); } - private enum RequestType { LOGIN, REGISTER, GET_LAST_CLIP, GET_ALL_CLIPS, SET_CLIP } + private enum RequestType { + LOGIN, + REGISTER, + GET_LAST_CLIP, + GET_ALL_CLIPS, + SET_CLIP + } - private static final String logtag = "NetClient"; - private static final int TIMEOUT_CONN = 8000; - private static final int TIMEOUT_READ = 30000; - private static final String URI_REGISTER = "/register/"; - private static final String URI_VERIFY = "/verify-user/"; - private static final String URI_CLIP = "/copy-paste/"; - // Captured before disableSSLCertChecks() can replace it, so it can be restored later - private static final HostnameVerifier DEFAULT_HOSTNAME_VERIFIER = HttpsURLConnection.getDefaultHostnameVerifier(); + /** 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; + } + } + + 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; + } + } + + 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 device_name; + private final String deviceName; + private final PinningTrustManager trustManager; + private final OkHttpClient client; - protected NetClient(Context context) { + public NetClient(Context context) { // We already have saved working credentials this(context, Utils.getCreds(context)); } - protected NetClient(Context context, Credentials creds) { + public NetClient(Context context, Credentials credentials) { this.context = context; this.appContext = context.getApplicationContext(); - this.credentials = creds; - this.device_name = getDeviceName(context); - if (credentials.ignore_cert) { - disableSSLCertChecks(); - } else { - enableSSLCertChecks(); + this.credentials = credentials; + this.deviceName = getDeviceName(context); + try { + 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); } } - private static final class Response { - int code; - String body = ""; - JSONArray clips = new JSONArray(); - // Prepared in the background for GET_LAST_CLIP, as images need to be decoded and stored - ClipData clipData; - String clipPreview; + public void login() { + execute(new ApiRequest(RequestType.LOGIN, PATH_VERIFY, null, null)); + } - boolean isOk() { - return code == 200 || code == 201; - } + public void register() { + execute(new ApiRequest( + RequestType.REGISTER, + PATH_REGISTER, + () -> new JSONObject() + .put("username", credentials.user) + .put("password", credentials.loginHash) + .toString(), + null)); + } + + public void getLastClip() { + execute(new ApiRequest(RequestType.GET_LAST_CLIP, PATH_CLIP, null, null)); + } + + 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 String getDeviceName(Context context) { + 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. @@ -102,9 +172,9 @@ private String getDeviceName(Context context) { */ 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"), + () -> Settings.Global.getString(cr, Settings.Global.DEVICE_NAME), + () -> Settings.Secure.getString(cr, "bluetooth_name"), + () -> Settings.System.getString(cr, "bluetooth_name"), }; for (SettingLookup lookup : lookups) { try { @@ -113,179 +183,172 @@ private String getDeviceName(Context context) { return name; } } catch (RuntimeException e) { - Log.d(logtag, "Device name lookup failed: " + e.getClass().getSimpleName()); + Log.d(TAG, "Device name lookup failed: " + e.getClass().getSimpleName()); } } return "android"; } - protected void Login() { - execute(RequestType.LOGIN, URI_VERIFY, null); - } - - protected void Register() { - execute(RequestType.REGISTER, URI_REGISTER, () -> { - JSONObject payload = new JSONObject(); - payload.put("username", credentials.user); - payload.put("password", credentials.login_pw_hash); - return payload.toString(); - }); - } - - protected void GetLastClipFromServer() { - execute(RequestType.GET_LAST_CLIP, URI_CLIP, null); - } - - protected void GetAllClipsFromServer() { - execute(RequestType.GET_ALL_CLIPS, URI_CLIP, null); - } - - protected void SetClipOnServer(String clip, String format) { - final String clip_format = format != null ? format : Utils.FORMAT_TXT; - execute(RequestType.SET_CLIP, URI_CLIP, () -> { - JSONObject payload = new JSONObject(); - payload.put("text", Crypto.encrypt(credentials.encryption_key, clip)); - payload.put("device", device_name); - payload.put("format", clip_format); - return payload.toString(); - }, Utils.clipPreview(appContext, clip, clip_format)); - } - - private void execute(RequestType type, String path, Async.Work payload) { - execute(type, path, payload, null); - } - - private void execute(RequestType type, String path, Async.Work payload, String preview) { - final String url = credentials.server + path; + private void execute(ApiRequest request) { notifyStarted(); - Async.run(() -> { - Response response = send(type, url, payload != null ? payload.run() : null); - if (response.isOk() && type != RequestType.SET_CLIP) { - response.clips = parseClips(response.body); - if (type == RequestType.GET_LAST_CLIP && response.clips.length() > 0) { - JSONObject last = response.clips.getJSONObject(response.clips.length() - 1); - String text = last.getString("text_decrypted"); - String format = last.optString("format", Utils.FORMAT_TXT); - response.clipData = Utils.createClipData(appContext, text, format); - response.clipPreview = Utils.clipPreview(appContext, text, format); - } - } - return response; - }, (response, error) -> { + Async.run(() -> perform(request), (result, error) -> { + boolean waitForUser = false; try { if (error != null) { - handleError(type, error); + handleError(request, error); } else { - handleResponse(type, response, preview); + waitForUser = handleResult(request, result); } } finally { - notifyFinished(); + if (!waitForUser) { + notifyFinished(); + } } }); } - private Response send(RequestType type, String request_uri, String payload) throws IOException { - Log.d(logtag, type + " " + request_uri); - HttpsURLConnection conn = (HttpsURLConnection) new URL(request_uri).openConnection(); - try { - conn.setConnectTimeout(TIMEOUT_CONN); - conn.setReadTimeout(TIMEOUT_READ); - conn.setRequestProperty("Content-type", "application/json; utf-8"); - conn.setRequestProperty("Accept", "application/json"); - if (type != RequestType.REGISTER) { - conn.setRequestProperty("Authorization", "Basic " + credentials.token_b64); - } - if (payload != null) { - conn.setRequestMethod("POST"); - conn.setDoOutput(true); - try (OutputStream os = conn.getOutputStream()) { - os.write(payload.getBytes(StandardCharsets.UTF_8)); - } - } else { - conn.setRequestMethod("GET"); - } - - Response response = new Response(); - response.code = conn.getResponseCode(); - Log.d(logtag, "Response code: " + response.code); - InputStream stream = response.code < 400 ? conn.getInputStream() : conn.getErrorStream(); - if (stream != null) { - response.body = readAll(stream); - } - return response; - } finally { - conn.disconnect(); + 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)); } - } - private static String readAll(InputStream stream) throws IOException { - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); + 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; } + throw e; } - return sb.toString(); - } - - private JSONArray parseClips(String body) { - // The server answers with an array of clips, or a single clip object - JSONArray clips; - try { - clips = new JSONArray(body); - } catch (JSONException e) { - clips = new JSONArray(); - try { - clips.put(new JSONObject(body)); - } catch (JSONException err) { - Log.e(logtag, "Could not parse response as JSON array or object"); - return clips; + 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 Utils.decryptClips(clips, credentials.encryption_key, - appContext.getString(R.string.error_decrypt_clip)); + return result; } - private void handleResponse(RequestType type, Response response, String preview) { - if (!response.isOk()) { - showError(type, response.code, parseErrorDetail(response.body)); - return; + /** 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); } - switch (type) { + 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(type == RequestType.LOGIN - ? R.string.msg_login_successful : R.string.msg_register_successful)); + 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 (response.clipData == null) { + if (result.clips.isEmpty()) { toast(appContext.getString(R.string.msg_no_clip_on_server)); } else { - Utils.setClipboard(appContext, response.clipData, response.clipPreview); + Utils.setClipboard(appContext, result.clipData, result.clipPreview); } break; case GET_ALL_CLIPS: - Clips.getInstance().setData(response.clips); + Clips.set(result.clips); startActivity(new Intent(context, ListClipsActivity.class), false); break; case SET_CLIP: - toast(appContext.getString(R.string.msg_clip_shared, preview)); + toast(appContext.getString(R.string.msg_clip_shared, request.preview)); break; } + return false; } - private void handleError(RequestType type, Exception error) { - Log.e(logtag, type + " failed: " + error); - String reason = error instanceof IOException - ? appContext.getString(R.string.error_connection, credentials.server) - : appContext.getString(R.string.error_unexpected); - toast(appContext.getString(R.string.error_request_failed, actionName(type), reason)); + 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); + } + // 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)); + } + notifyFinished(); + }) + .show(); + return true; } - private void showError(RequestType type, int code, String detail) { + 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); + } + + private void showHttpError(RequestType type, int code, String detail) { String reason; if (code == 401 || code == 403) { reason = appContext.getString(R.string.error_unauthorized); @@ -294,16 +357,25 @@ private void showError(RequestType type, int code, String detail) { } else { reason = appContext.getString(R.string.error_http_code, code); } + showFailure(type, reason); + } + + private void showFailure(RequestType type, String reason) { toast(appContext.getString(R.string.error_request_failed, actionName(type), reason)); } 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); + 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); } } @@ -344,45 +416,4 @@ private void notifyFinished() { ((Listener) context).onRequestFinished(); } } - - 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 - */ - Log.d(logtag, "Disabling SSL Cert Checks"); - TrustManager[] trustAllCerts = new TrustManager[] { - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - @SuppressLint("TrustAllX509TrustManager") - public void checkClientTrusted(X509Certificate[] certs, String authType) { - } - @SuppressLint("TrustAllX509TrustManager") - public void checkServerTrusted(X509Certificate[] certs, String authType) { - } - } - }; - try { - SSLContext sc = SSLContext.getInstance("TLS"); - sc.init(null, trustAllCerts, new java.security.SecureRandom()); - HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - } catch (GeneralSecurityException e) { - Log.e(logtag, e.toString()); - } - } - - public static void enableSSLCertChecks() { - Log.d(logtag, "Enabling SSL Cert Checks"); - try { - SSLContext sc = SSLContext.getInstance("TLS"); - sc.init(null, null, null); - HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); - HttpsURLConnection.setDefaultHostnameVerifier(DEFAULT_HOSTNAME_VERIFIER); - } catch (GeneralSecurityException e) { - Log.e(logtag, "Error enabling SSL Cert: " + e); - } - } } 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 2ab3e56..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,28 +1,25 @@ package com.data_dive.com.clipster; -import androidx.activity.EdgeToEdge; -import androidx.appcompat.app.AppCompatActivity; - import android.content.Intent; import android.os.Bundle; 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 { - private MaterialButton get_last_clip, get_all_clips, set_clip, edit_creds; + private static final int LOCAL_NETWORK_REQUEST = 1; + + private MaterialButton getLastClip, getAllClips, shareClipboard, editCreds; private LinearProgressIndicator progress; - private int running_requests = 0; + private int runningRequests = 0; @Override protected void onCreate(Bundle savedInstanceState) { @@ -42,53 +39,69 @@ protected void onCreate(Bundle savedInstanceState) { ((TextView) findViewById(R.id.active_server)).setText(creds.server); progress = findViewById(R.id.progress); - 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(v -> new NetClient(this).GetLastClipFromServer()); - get_all_clips.setOnClickListener(v -> new NetClient(this).GetAllClipsFromServer()); - set_clip.setOnClickListener(v -> shareClipboard()); - edit_creds.setOnClickListener(v -> { - Intent i = new Intent(this, MainActivity.class); - i.setAction(Intent.ACTION_EDIT); - startActivity(i); + 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 && MainActivity.ACTION_GET_LAST_CLIP.equals(getIntent().getAction())) { - new NetClient(this).GetLastClipFromServer(); + if (savedInstanceState == null) { + requestLocalNetworkIfNeeded(creds.server); + if (MainActivity.ACTION_GET_LAST_CLIP.equals(getIntent().getAction())) { + new NetClient(this).getLastClip(); + } + } + } + + /** 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); + } + }); } private void shareClipboard() { - String clip = Utils.checkClipboard(this); + String clip = Utils.readClipboardText(this); if (clip.isEmpty()) { - Toast.makeText(this, R.string.msg_clipboard_empty, Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.msg_clipboard_empty, Toast.LENGTH_LONG) + .show(); return; } - new NetClient(this).SetClipOnServer(clip, Utils.FORMAT_TXT); + new NetClient(this).shareClip(clip, Clip.FORMAT_TXT); } @Override public void onRequestStarted() { - running_requests++; + runningRequests++; updateLoading(); } @Override public void onRequestFinished() { - running_requests = Math.max(0, running_requests - 1); + runningRequests = Math.max(0, runningRequests - 1); updateLoading(); } private void updateLoading() { - boolean loading = running_requests > 0; + boolean loading = runningRequests > 0; progress.setVisibility(loading ? View.VISIBLE : View.INVISIBLE); - get_last_clip.setEnabled(!loading); - get_all_clips.setEnabled(!loading); - set_clip.setEnabled(!loading); - edit_creds.setEnabled(!loading); + getLastClip.setEnabled(!loading); + getAllClips.setEnabled(!loading); + shareClipboard.setEnabled(!loading); + editCreds.setEnabled(!loading); } } 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 45b7995..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,20 +1,20 @@ package com.data_dive.com.clipster; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.IntentCompat; - import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; /** - * Add our app to "share with" menu when sharing texts and images. - * Shows a small progress card until the clip is uploaded, then finishes. + * 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 { + private int runningRequests = 0; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -37,47 +37,54 @@ protected void onCreate(Bundle savedInstanceState) { if (!Intent.ACTION_SEND.equals(intent.getAction()) || type == null) { finish(); } else if (type.equals("text/plain")) { - handleSharedText(intent); + shareText(intent); } else if (type.startsWith("image/")) { - handleSharedImage(intent); + shareImage(intent); } else { - Toast.makeText(this, getString(R.string.error_share_unknown_type, type), Toast.LENGTH_LONG).show(); + Toast.makeText(this, getString(R.string.error_share_unknown_type, type), Toast.LENGTH_LONG) + .show(); finish(); } } - private void handleSharedImage(Intent intent) { + private void shareImage(Intent intent) { Uri imageUri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri.class); if (imageUri == null) { finish(); return; } // Decoding and re-encoding large images takes a while - Async.run(() -> Utils.ImageUriToB64String(this, imageUri), (imageString, error) -> { - if (imageString == null) { - Toast.makeText(this, R.string.error_open_image, Toast.LENGTH_LONG).show(); + 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).SetClipOnServer(imageString, Utils.FORMAT_IMG); + new NetClient(this).shareClip(image, Clip.FORMAT_IMG); }); } - private void handleSharedText(Intent intent) { - String shared_clip = intent.getStringExtra(Intent.EXTRA_TEXT); - if (shared_clip == null || shared_clip.isEmpty()) { + private void shareText(Intent intent) { + String text = intent.getStringExtra(Intent.EXTRA_TEXT); + if (text == null || text.isEmpty()) { finish(); return; } - new NetClient(this).SetClipOnServer(shared_clip, Utils.FORMAT_TXT); + new NetClient(this).shareClip(text, Clip.FORMAT_TXT); } @Override public void onRequestStarted() { + runningRequests++; } @Override public void onRequestFinished() { - finish(); + // 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 957f754..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,5 +1,6 @@ package com.data_dive.com.clipster; +import static android.content.Context.CLIPBOARD_SERVICE; import android.content.ClipData; import android.content.ClipboardManager; @@ -16,15 +17,9 @@ 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.macasaet.fernet.Key; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -32,17 +27,15 @@ import java.io.OutputStream; import java.util.Locale; -import static android.content.Context.CLIPBOARD_SERVICE; - /** - * 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 final 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,18 +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"; - - public static final String FORMAT_TXT = "txt"; - public static final String FORMAT_IMG = "img"; + private static final String PREF_CRED_ALLOW_SELF_SIGNED = "cred_ignore_cert"; + private static final String PREF_CRED_PINNED_CERT = "cred_pinned_cert"; 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. + // 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; @@ -70,108 +61,113 @@ public static boolean areCredsSaved(Context context) { getCreds(context); return true; } catch (RuntimeException e) { - Log.e(logtag, "Saved credentials are invalid, clearing them: " + e.getClass().getSimpleName()); + Log.e( + TAG, + "Saved credentials are invalid, clearing them: " + + e.getClass().getSimpleName()); clearCreds(context); return false; } } public static void saveCreds(Context context, Credentials creds) { - Log.d(logtag, "saveCreds: " + creds); - 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); - return Credentials.fromSavedHashes( + 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_IGNORE_CERT, false)); + pref.getBoolean(PREF_CRED_ALLOW_SELF_SIGNED, false), + pref.getString(PREF_CRED_PINNED_CERT, "")); } public static void clearCreds(Context context) { - SharedPreferences pref = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE); - pref.edit().clear().apply(); + context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE) + .edit() + .clear() + .apply(); } - public static String clipPreview(Context context, String clip_text, String clip_format) { - if (FORMAT_IMG.equals(clip_format)) { + 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 (clip_text.length() > MAX_CLIP_SHOW_LEN) { - return clip_text.substring(0, MAX_CLIP_SHOW_LEN) + " [...]"; + if (text.length() > MAX_CLIP_SHOW_LEN) { + return text.substring(0, MAX_CLIP_SHOW_LEN) + " [...]"; } - return clip_text; + return text; } /** * 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, String clip_text, String clip_format) { - if (FORMAT_IMG.equals(clip_format)) { - Uri imageUri = BitmapToTempFileAsUri(context, B64StringToImage(clip_text)); + 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); + return ClipData.newPlainText("Clipster", clip.text); } public static void setClipboard(Context context, ClipData clip, String preview) { if (clip == null) { - Toast.makeText(context, R.string.error_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); // 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(); + Toast.makeText(context, context.getString(R.string.msg_clipboard_set, preview), Toast.LENGTH_LONG) + .show(); } } - public static String checkClipboard(Context context) { - 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(); } } - return clip; + return ""; } - 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(); + public static boolean isValidServerUri(String serverUri) { + if (serverUri.startsWith("https://localhost")) { + return true; + } + return Patterns.WEB_URL.matcher(serverUri).matches(); } - public static String formatURIProtocol(String server_uri) { - /* - * Make sure we always use https:// and have no trailing slashes in URI - */ - String uri = server_uri.trim().replaceFirst("/+$", ""); + /** + * 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()); @@ -182,13 +178,13 @@ public static String formatURIProtocol(String server_uri) { return "https://" + uri; } - public static Bitmap B64StringToImage(String imgString) { - // Decode base64 string containing image to Bitmap image, null if it's not a valid image + /** Decode a base64 image, null if it's not a valid image */ + public static Bitmap b64ToBitmap(String b64) { try { - byte[] imgBytes = Base64.decode(imgString, Base64.DEFAULT); - return BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.length); + byte[] bytes = Base64.decode(b64, Base64.DEFAULT); + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } catch (IllegalArgumentException e) { - Log.e(logtag, "Error B64StringToImage: " + e); + Log.e(TAG, "Invalid base64 image: " + e); return null; } } @@ -197,101 +193,78 @@ public static Bitmap B64StringToImage(String imgString) { * 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 B64StringToThumbnail(String imgString, int maxSize) { + public static Bitmap b64ToThumbnail(String b64, int maxSize) { try { - byte[] imgBytes = Base64.decode(imgString, Base64.DEFAULT); + byte[] bytes = Base64.decode(b64, Base64.DEFAULT); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inJustDecodeBounds = true; - BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.length, opts); + 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(imgBytes, 0, imgBytes.length, opts); + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opts); } catch (IllegalArgumentException e) { - Log.e(logtag, "Error B64StringToThumbnail: " + e); + Log.e(TAG, "Invalid base64 image: " + e); return null; } } - 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; } ByteArrayOutputStream bos = new ByteArrayOutputStream(); - if (!imageBitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)) { - Log.e(logtag, "Error BitmapToB64String: compress failed"); + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)) { + Log.e(TAG, "Could not compress image"); return null; } return Base64.encodeToString(bos.toByteArray(), Base64.DEFAULT); } - /** - * Decrypt all clips and add the cleartext as "text_decrypted". Clips that can't be decrypted - * get errorText instead, so one bad clip doesn't hide the others. - */ - public static JSONArray decryptClips(JSONArray clips, Key key, String errorText) { - for (int i = 0; i < clips.length(); i++) { - try { - JSONObject clip = clips.getJSONObject(i); - String text_decrypted; - try { - text_decrypted = Crypto.decrypt(key, clip.getString("text")); - } catch (RuntimeException e) { - Log.e(logtag, "Could not decrypt clip " + i + ": " + e.getClass().getSimpleName()); - text_decrypted = errorText; - clip.put("format", FORMAT_TXT); - } - clip.put("text_decrypted", text_decrypted); - } catch (JSONException e) { - Log.e(logtag, "Invalid clip " + i + ": " + e); - } - } - return clips; - } - - public static Uri BitmapToTempFileAsUri(Context context, Bitmap bitmap) { - // Store Bitmap image to a temp .png file and return uri via FileProvider, null on failure + /** Store an image in a temp PNG file and return its FileProvider uri, null on failure */ + public static Uri bitmapToTempFileUri(Context context, Bitmap bitmap) { if (bitmap == null) { return null; } File file = new File(context.getCacheDir(), "tmp.png"); - try (FileOutputStream fOut = new FileOutputStream(file)) { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut); + try (FileOutputStream out = new FileOutputStream(file)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); } catch (IOException e) { - Log.e(logtag, "Error writing temp image: " + e); + Log.e(TAG, "Error writing temp image: " + e); return null; } return FileProvider.getUriForFile(context, context.getPackageName() + ".provider", file); } - public static Bitmap ImageUriToBitmap(Context context, Uri imageUri) { - // Load an image from a content URI, null if it can't be read + /** Load an image from a content uri, null if it can't be read */ + public static Bitmap imageUriToBitmap(Context context, Uri imageUri) { ContentResolver cr = context.getContentResolver(); try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // Software bitmap, hardware bitmaps can't be compressed to PNG - return ImageDecoder.decodeBitmap(ImageDecoder.createSource(cr, imageUri), + 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(logtag, "Error reading shared image: " + e); + Log.e(TAG, "Error reading shared image: " + e); return null; } } - public static String ImageUriToB64String(Context context, Uri imageUri) { - return BitmapToB64String(ImageUriToBitmap(context, imageUri)); + public static String imageUriToB64(Context context, Uri imageUri) { + return bitmapToB64(imageUriToBitmap(context, imageUri)); } /** - * Save image to the gallery (Pictures/Clipster from Android 10 on). Returns true on success. + * Save an image to the gallery (Pictures/Clipster from Android 10 on). Returns true on success. */ - public static boolean SaveBitmapToGallery(Context context, Bitmap image) { + public static boolean saveBitmapToGallery(Context context, Bitmap image) { if (image == null) { return false; } @@ -311,12 +284,11 @@ public static boolean SaveBitmapToGallery(Context context, Bitmap image) { return true; } } catch (IOException e) { - Log.e(logtag, "Error saving image: " + e); + Log.e(TAG, "Error saving image: " + e); } cr.delete(uri, null, null); return false; } return MediaStore.Images.Media.insertImage(cr, image, name, "Image shared via Clipster") != null; } - } diff --git a/app/src/main/res/drawable/clipster_250_banner.png b/app/src/main/res/drawable-nodpi/clipster_250_banner.png similarity index 100% rename from app/src/main/res/drawable/clipster_250_banner.png rename to app/src/main/res/drawable-nodpi/clipster_250_banner.png diff --git a/app/src/main/res/layout/activity_list_clips.xml b/app/src/main/res/layout/activity_list_clips.xml index f6fafd2..8d128fa 100644 --- a/app/src/main/res/layout/activity_list_clips.xml +++ b/app/src/main/res/layout/activity_list_clips.xml @@ -20,7 +20,7 @@ android:layout_weight="1"> + + android:text="@string/allow_self_signed" /> + - + - + diff --git a/app/src/main/res/layout/list_single_img.xml b/app/src/main/res/layout/list_single_img.xml index b4ecab0..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 @@ - + + 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 a193819..e1f3483 100644 --- a/app/src/main/res/layout/list_single_txt.xml +++ b/app/src/main/res/layout/list_single_txt.xml @@ -1,11 +1,27 @@ - + 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 f6eee0201521b0caa519bdc6d43e9f7fc5b0dbbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2210 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 T9!zG_00000NkvXXu0mjf&x6t> 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 0b0c04ea91f50bf2fdd1ff3f0b0def1c3ac563b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1470 zcmV;v1ws0WP)X=>nPD&xQmxL`B?0ug_O<39(93N`9SE@WioQmBYZ<*t8>3TX^QMa#%t zsgw#er4%>NRIJ=E_p22ygFqBZ(We2D7uX{|ah)+X_$w%coDDeh~sDaFZC9#3+eTBUoK~3C0w>ChO|z zY?Qff6kvd{ySv-|+qZA>RRNNwEe{T>0%l7~OP!UuZYiL?zTQCyn9)W71qB61R286= zJCK{3`=?HTPJm8;8Up6$=jqK2B26c3FkXVQ3KYaL*-oJm(^ZWYxNYo;F^5h9f?_GcZd3$^F zg36Xr7J!`?A0MZHfB@bNk*~?u*Oz8yX6W?k(^OJYLWPBeG&nfOJD8G^La$%HW|EgK zU82g$O7ijXq0!M%iiwG#pr9bWHa9o7cLA`{i4!N-y4wjjbLI??o&ERv_3KO==McoO^2Edh2l>T|7kLg`1wNddoJ=`6IsA81Qxg>x z715O|SE#D0iiP1BxSpDtN*_Odq?cm{(0%a<>jsROrf-(JfmKW!m{ z{{yhD@_ASv=P_!f0Ju?NVj_8Yd6B!jJI6(9Yb#$v4M3nDJ9dmya%N^GyO>r8fTcm` z(4j-5r>94FlmcdEW}F@k4Gq_w*Vor42L}hvN*Waa@+eBCrl#u%Dr2K4Wms8RQF(d! z|0h8Btc0AboYs_)mzPHe4jkB)penGnwdJb3@!7~os3{_QsW0H^)2Gt$$00i0D+3iXU0#U$> zjg5Dm>*C_Vl^bg*vKH!rsP+=pQ%ito$|OfeM@maeW765#+1zxQo15=Q20`lNPA%r-VQJH~^;oshjAJa|B5Wo6tK+1uMoaACCsi0UhOcz7@&Ra^n{4j(?u9p*-t zK$C~w>EOYGy9!YN*(*3Wm>Wv9vk>}v^eYw?79^9&HW4;5GU7l!dh{s8$H#M zKFD3P5&wv{DvbbZ|1A!1+12_RMVvZy%1Hb)6)@K@OuI_Z+hO8@`>07*qoM6N<$f-4%&{{R30 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 2498069441ef7b416eff05b119607b7d7e65a043..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2511 zcmV;=2{87FP)_RXev+#5K$?A(r3|LsSq&D!6tHC0UL)-Y8Scvr*qUm0|g0XP5^S^oDTC#A$({+xt8?Co6@;KLcX z*|52}0-)`RzLrIIEnJ6JnuKv8FSuE0UDujcRp4!0ut|YkLcmSe{8Xj+Y6fp5!TVc^ zii#*VH+Py5Te*3j{qHX-V5`Gz%5oo~CB=x}e_-ILn3$ZLZ0^;o7o9wLk{&#GAmI1!-%nq@ zd?DP=$;qLI4n^JmuIz4^*ROW8pyqV_Bn3z*7(i2|Orde(#!*H_28|juin@32PRo`p zlh1K+aWs7RaGE@MvWx*4D6DVazBF~}R9d@sEio<>I19%DJ%0T7w_;W>rY2^dpcj#% zfddE9sZ*y)nAAvM@#4i46B9!(U%sR^ZQ4*uN(z1Y^oeH7m_d8?>=A-<=FAaL00B$w z*s+6FtXLuIynFXf$XvX5k)AwxB4Z;XBkAVNo7AmaH@OGU##mr0|Jx7?CToFUjrsGS zd=>rB%7Ft19464acW=3eFAd)mEwH?3a1TE>A`^XAPd zEG$f74AzN?iV^~_C|t>yRv9bmLOv4?vKBX$me{|3)7?kfgk8IK(Xe5|=);E(vH-LP zlb1%IUAuNLuZ){Jcdh_|e`7AjhJ=JjJnh}PSIFT$EYP)USIW%HG-e$kfW4LW4K28a zd-w0(pJ+{~$^r~@n=$V*AptUC#0XljV1f8I0uWjW4GpEYZ{JFc96fqe2z2PsfhJCz zNb~2DjYq zy%=nIMJq2d4ziC*gWtO{vrA^M#k{QqSi8SxO}wfo0caI90Pod7fRjNsXER?b0WMm8 zy!pGz^gbW}H$Y|PAS>p-aJ{QnuhOw&$Hb*nVyGwqo}YW@(4k+f1QHVy{VPfUpyB_u zYu6?u28hXIqAp#!5Vs;y5B&Z8t=~cF)vHI7CQXuHttf$=J9h?I39#0hadydeh5!P5 z`}XbBq)8J2Yy_xCAjj9QU%$k65LZ`MYSpThv?z|co+7{*^7V!d8~m&UX3w7O&1QBu zh>;xt9-6cV&KdFZ^Aopnh5);32B-2y#(u62XON3}8)1RJVo}>PrUXr0qehJ?4PFT> zf#PLvFSI6bQ}jnlxm;tCc-{}jM$V0M>kfQ*`0!y%j~+c})22<59&I&`2CZ4MW>p3r z;^N{WF@qM}mH=wR(xpqKH%d)Sl|pv+?%lmS&t0(Vt%QjSOBno`E3d{mR)b$6dl(mk zRa&=h?J%&wz(A+m3>6v$&$uTrzzST+HEs?ybONVOpPsH);%}`4_ddV!!1rit`}FB! zfk~keq(^2Q1UN(hf!(%kTghCg_She10tO7lSqAp)6-KrPykRx4()Q!o;{1D z+Oh=WTDEK{`*-Tpi5fR<3@Ji?#cq_yW9k(<3aB_ZfByVrY&dJyERBI^&iFCXB|1n1 zoV{Q~7YWUxldW5~u2V7MB8-rn9NtmEO~K~0-B zrGEYT5i8aV1+WFu`QYSh_h7Fxs>>RTfECbtGZ~Gmv>Za#tXV@VSFV(UJklsM&E}MX zR?*%Dgwd5IBqWG+)~#D7Z8h3-?132KAl4Y0(*6QqwEKL>z{rQ&8;tD_ z0LUjr*Rr0<)nQEpb5CBVKUOr?6nORoGmepbt|(lzXwe{GT(F%#gALw7?gCp5L7JMchI{0z=1vV z1`e8U0Sr=cEArAOf`l*pkke{`{&+^KDk28pvKH5g0xnwrz@C)?Z*-@iprG3DLq0bQ z;kGsz)0L7l=Bm-#V}sn1#3k|M=#;E|AalT*LV3Ff}VrnQjHrnZ1`(bR8$0?4-#{7 za`K=-bu`qiQ|zUFB*Yk0Rd$s;uEL2}18ZSThw^+?SVP~$gXJfdKZb;a{GHqJdEB$a zvKu6!XW=&dB^RJK5L{$GC1DK4Vh+#!MPI`MYc5>4utxqf0Wv@8KoEF=+&%u8 zPf%TggM)iPaE*KTP9O7=KF7V<|LkGUBFGdeDJiw}I8oNY{~vy*i>H3iUH5Ny%&7`L Z|1U-}@C$0!bt3=(002ovPDHLkV1mCTzKs9? 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 4f63baf1da0c2fe5af176500f660e19ad480e712..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3043 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 Y0U9~mhT@#q-~a#s07*qoM6N<$f=@t2djJ3c 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 3f6b6b289d5a69cbdfcb9f30cc7fe92a046d2eb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4795 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 Sp^q6#0W{V0RNpJxM*R=0%=x1L 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 aba12a57431153ba7da9a4afe5aa6fcf6a25caa1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9216 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 LYon?ZtwR0_f5?Yy 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 f6253937a0f38de8ec1330b6ba5954293d89c3f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13339 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 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e662fdc..8944754 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,19 +1,19 @@ - - - - #000000 - #FFFFFF - #E2E2E2 - #1B1B1B - #EAEAEA - #1B1B1B - #FFFFFF - #1B1B1B - #474747 - #F6F6F6 - #F0F0F0 - #EAEAEA - #E4E4E4 - #767676 - #D0D0D0 - + + + + #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 deb46ce..107c4fe 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,9 +1,9 @@ - - 24dp - - 480dp - 8dp - 16dp - 32dp - 200dp - + + 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 6abc7e7..82e7bb6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,65 +1,81 @@ - - Clipster - https://clipster.cc - - - Login - Register - Username - Password - Server - Leave empty to use the public server - Disable SSL certificate check (only use with own server) - 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 - - - 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 - 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 - + + 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/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/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 index 3f65ae8..760cc3c 100644 --- a/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java +++ b/app/src/test/java/com/data_dive/com/clipster/CryptoTest.java @@ -4,12 +4,10 @@ import static org.junit.Assert.assertThrows; import com.macasaet.fernet.Key; - -import org.junit.Test; - 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 @@ -42,22 +40,24 @@ public void deriveKey_msgHash_matchesOtherClients() { @Test public void deriveKey_nonAsciiPassword_isUtf8Encoded() { - assertEquals("da807iYOiUZngQv_La7PBS0OBX4t-4mXVIKqlPWkVMg=", + assertEquals( + "da807iYOiUZngQv_La7PBS0OBX4t-4mXVIKqlPWkVMg=", Crypto.deriveKey("bob", "pässwörd-ü€", Crypto.ITERS_LOGIN_HASH)); - assertEquals("U5apiIs8RYDrzHrpqGNxtGf0w1MfIJaZYpx4RRTd0WE=", + assertEquals( + "U5apiIs8RYDrzHrpqGNxtGf0w1MfIJaZYpx4RRTd0WE=", Crypto.deriveKey("bob", "pässwörd-ü€", Crypto.ITERS_MSG_HASH)); } @Test public void authToken_isBase64OfUserAndLoginHash() { - assertEquals("YWxpY2U6YkJ0aFhnOU9IdG9CaHpWUWFDdkotdFQ2U0JObTNrXzhkODRPRURWamw4RT0=", + 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)); + assertEquals("Hello from Clipster-Desktop ✂", Crypto.decrypt(new Key(MSG_HASH), TOKEN, AFTER_TOKEN_CREATION)); } @Test @@ -81,8 +81,8 @@ public void decrypt_malformedToken_throws() { @Test public void credentials_fromPassword_matchesSavedHashes() { Credentials fromPw = Credentials.fromPassword(USER, PASSWORD, "https://clipster.cc", false); - assertEquals(LOGIN_HASH, fromPw.login_pw_hash); - assertEquals(MSG_HASH, fromPw.msg_pw_hash); - assertEquals(Crypto.authToken(USER, LOGIN_HASH), fromPw.token_b64); + 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 index de446b8..ed33b37 100644 --- a/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java +++ b/app/src/test/java/com/data_dive/com/clipster/UtilsTest.java @@ -2,56 +2,24 @@ import static org.junit.Assert.assertEquals; -import com.macasaet.fernet.Key; - -import org.json.JSONArray; -import org.json.JSONObject; import org.junit.Test; public class UtilsTest { - private static final Key KEY = new Key("BYVy_Lfm1pO5uSEQRq3UmHDegjhE2RpFt60PVZJjfp4="); - - @Test - public void formatURIProtocol_addsHttpsWhenMissing() { - assertEquals("https://clipster.cc", Utils.formatURIProtocol("clipster.cc")); - } - - @Test - public void formatURIProtocol_upgradesHttp() { - assertEquals("https://example.org:9999", Utils.formatURIProtocol("http://example.org:9999")); - assertEquals("https://example.org", Utils.formatURIProtocol("HTTP://example.org")); - } - @Test - public void formatURIProtocol_keepsHttpsAndStripsTrailingSlashesAndWhitespace() { - assertEquals("https://example.org/sub", Utils.formatURIProtocol(" https://example.org/sub// ")); - assertEquals("https://Example.org", Utils.formatURIProtocol("HTTPS://Example.org/")); + public void formatServerUri_addsHttpsWhenMissing() { + assertEquals("https://clipster.cc", Utils.formatServerUri("clipster.cc")); } @Test - public void decryptClips_addsCleartext() throws Exception { - JSONArray clips = new JSONArray() - .put(new JSONObject().put("text", Crypto.encrypt(KEY, "first")).put("format", "txt")) - .put(new JSONObject().put("text", Crypto.encrypt(KEY, "second")).put("format", "txt")); - - Utils.decryptClips(clips, KEY, "error"); - - assertEquals("first", clips.getJSONObject(0).getString("text_decrypted")); - assertEquals("second", clips.getJSONObject(1).getString("text_decrypted")); + 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 decryptClips_badClipDoesNotHideOthers() throws Exception { - JSONArray clips = new JSONArray() - .put(new JSONObject().put("text", "garbage").put("format", "img")) - .put(new JSONObject().put("text", Crypto.encrypt(KEY, "ok")).put("format", "txt")); - - Utils.decryptClips(clips, KEY, "error"); - - assertEquals("error", clips.getJSONObject(0).getString("text_decrypted")); - // Shown as text, not handed to the image decoder - assertEquals("txt", clips.getJSONObject(0).getString("format")); - assertEquals("ok", clips.getJSONObject(1).getString("text_decrypted")); + 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" }