diff --git a/.github/workflows/build_f90984b8-b8a5-4091-b2f0-aa078a86be2c.yml b/.github/workflows/build_f90984b8-b8a5-4091-b2f0-aa078a86be2c.yml
new file mode 100644
index 00000000..1bc4e5b5
--- /dev/null
+++ b/.github/workflows/build_f90984b8-b8a5-4091-b2f0-aa078a86be2c.yml
@@ -0,0 +1,102 @@
+name: Build APK f90984b8-b8a5-4091-b2f0-aa078a86be2c
+
+on:
+ push:
+ tags: [ 'V*' ]
+ pull_request:
+ branches: [ main, master ]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ env:
+ HAS_KEYSTORE: ${{ secrets.SIGNING_KEYSTORE_BASE64 != '' }}
+
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Extract version info
+ id: version
+ run: |
+ VERSION_CODE=$(grep 'versionCode' app/build.gradle | tr -dc '0-9')
+ VERSION_NAME=$(grep 'versionName' app/build.gradle | sed 's/.*"\(.*\)".*/\1/')
+ TAG="${GITHUB_REF_NAME:-dev}"
+ TAG="${TAG#refs/tags/}"
+ echo "code=$VERSION_CODE" >> $GITHUB_OUTPUT
+ echo "name=$VERSION_NAME" >> $GITHUB_OUTPUT
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ # ── Build Debug ────────────────────────────────────────────────────────
+ - name: Build Debug APK
+ run: ./gradlew assembleDebug --no-daemon
+
+ # ── Build Release (signed) ─────────────────────────────────────────────
+ - name: Decode Release Keystore
+ if: env.HAS_KEYSTORE == 'true'
+ run: |
+ mkdir -p app/keystore
+ echo "${{ secrets.SIGNING_KEYSTORE_BASE64 }}" | base64 -d > app/keystore/release.keystore
+
+ - name: Build Release APK (signed)
+ if: env.HAS_KEYSTORE == 'true'
+ env:
+ SIGNING_KEYSTORE_PATH: ${{ github.workspace }}/app/keystore/release.keystore
+ SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
+ SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
+ SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
+ run: ./gradlew assembleRelease --no-daemon
+
+ - name: Build Release APK (debug keystore fallback)
+ if: env.HAS_KEYSTORE != 'true'
+ run: ./gradlew assembleRelease --no-daemon
+
+ # ── Rename & Upload ───────────────────────────────────────────────────
+ - name: Rename APKs
+ run: |
+ TAG="${{ steps.version.outputs.tag }}"
+ mkdir -p artifacts
+ cp app/build/outputs/apk/debug/app-debug.apk artifacts/codeboard-${TAG}-debug.apk
+ cp app/build/outputs/apk/release/app-release.apk artifacts/codeboard-${TAG}-release.apk
+ cp artifacts/codeboard-${TAG}-release.apk artifacts/codeboard-release.apk
+
+ - name: Upload Debug APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: codeboard-debug
+ path: artifacts/codeboard-*-debug.apk
+ retention-days: 30
+
+ - name: Upload Release APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: codeboard-release
+ path: artifacts/codeboard-*-release.apk
+ retention-days: 30
+
+ # ── Create Release (only when tag V*) ───────────────────────────────
+ - name: Create GitHub Release
+ if: startsWith(github.ref, 'refs/tags/V')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ TAG="${{ steps.version.outputs.tag }}"
+ CHANGELOG=$(git log -1 --pretty=%s)
+ gh release create "$TAG" \
+ artifacts/codeboard-${TAG}-release.apk \
+ artifacts/codeboard-release.apk \
+ --title "Codeboard $TAG" \
+ --notes "$CHANGELOG" \
+ --latest
diff --git a/.gitignore b/.gitignore
index 65911352..ac248032 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,3 +89,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
+
+# Archives - do not ignore
+!archives/logs/activity.log
diff --git a/app/build.gradle b/app/build.gradle
index e42c2551..2512b326 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,14 +9,68 @@ android {
applicationId "com.gazlaws.codeboard"
minSdkVersion 23
targetSdk 35
- versionCode 23
- versionName "6.0.3"
+ versionCode 48
+ versionName "6.5.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+ signingConfigs {
+ release {
+ def keystorePath = System.getenv("SIGNING_KEYSTORE_PATH") ?: ""
+ def localKeystorePath = ""
+ def props = new Properties()
+ def localProps = rootProject.file("local.properties")
+ if (localProps.exists()) {
+ props.load(localProps.newDataInputStream())
+ localKeystorePath = props.getProperty("signing.keystore.path", "")
+ }
+ def debugKeystore = rootProject.file("app/keystore/debug.keystore")
+
+ if (keystorePath != "") {
+ // Stage 1: env variable (GitHub Actions / terminal)
+ storeFile file(keystorePath)
+ storePassword System.getenv("SIGNING_STORE_PASSWORD") ?: ""
+ keyAlias System.getenv("SIGNING_KEY_ALIAS") ?: ""
+ keyPassword System.getenv("SIGNING_KEY_PASSWORD") ?: ""
+ storeType "PKCS12"
+ } else if (localKeystorePath != "") {
+ // Stage 2: local.properties (Android Studio)
+ storeFile file(localKeystorePath)
+ storePassword props.getProperty("signing.store.password", "")
+ keyAlias props.getProperty("signing.key.alias", "")
+ keyPassword props.getProperty("signing.key.password", "")
+ storeType "PKCS12"
+ } else if (debugKeystore.exists()) {
+ // Stage 3: custom app/keystore/debug.keystore (if present)
+ storeFile debugKeystore
+ storePassword "android"
+ keyAlias "androiddebugkey"
+ keyPassword "android"
+ }
+ // Stage 4: everything empty -> storeFile is not set.
+ // signingConfig is NOT assigned to buildTypes.release (see below),
+ // so AGP falls back to its default behavior.
+ }
+ }
+
+ // Only assign signingConfig if a valid keystore actually exists (Stage 1-3).
+ // If none exists (Stage 4), signingConfig is NOT set at all —
+ // AGP automatically uses the system's built-in debug keystore.
+ def hasSigningConfig = (System.getenv("SIGNING_KEYSTORE_PATH") ?: "") != "" ||
+ ({
+ def p = new Properties()
+ def lp = rootProject.file("local.properties")
+ if (lp.exists()) p.load(lp.newDataInputStream())
+ return (p.getProperty("signing.keystore.path", "") != "")
+ })() ||
+ rootProject.file("app/keystore/debug.keystore").exists()
+
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ if (hasSigningConfig) {
+ signingConfig signingConfigs.release
+ }
}
}
testOptions {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 26c37854..7659a584 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/gazlaws/codeboard/CodeBoardIME.java b/app/src/main/java/com/gazlaws/codeboard/CodeBoardIME.java
index 6fbba24f..224d43e3 100644
--- a/app/src/main/java/com/gazlaws/codeboard/CodeBoardIME.java
+++ b/app/src/main/java/com/gazlaws/codeboard/CodeBoardIME.java
@@ -36,6 +36,10 @@
import androidx.core.app.RemoteInput;
import androidx.core.graphics.ColorUtils;
+import com.gazlaws.codeboard.clipboard.ClipboardEntry;
+import com.gazlaws.codeboard.clipboard.ClipboardMonitor;
+import com.gazlaws.codeboard.clipboard.ClipboardPrefs;
+import com.gazlaws.codeboard.clipboard.ClipboardStorage;
import com.gazlaws.codeboard.layout.Box;
import com.gazlaws.codeboard.layout.Definitions;
import com.gazlaws.codeboard.layout.Key;
@@ -63,6 +67,10 @@ public class CodeBoardIME extends InputMethodService
implements KeyboardView.OnKeyboardActionListener {
private static final String NOTIFICATION_CHANNEL_ID = "Codeboard";
EditorInfo sEditorInfo;
+
+ // Clipboard history
+ private ClipboardMonitor clipboardMonitor;
+
private boolean vibratorOn;
private int vibrateLength;
private boolean soundOn;
@@ -105,7 +113,14 @@ public void onKey(int primaryCode, int[] KeyCodes) {
break;
case -1:
//SYM
- if (mKeyboardState == R.integer.keyboard_normal && !ctrl) {
+ if (ctrl && shift) {
+ // Ctrl + Shift + SYM → toggle HIST mode
+ if (mKeyboardState == R.integer.keyboard_history) {
+ mKeyboardState = R.integer.keyboard_normal;
+ } else {
+ mKeyboardState = R.integer.keyboard_history;
+ }
+ } else if (mKeyboardState == R.integer.keyboard_normal && !ctrl) {
mKeyboardState = R.integer.keyboard_sym;
} else if (ctrl) {
mKeyboardState = R.integer.keyboard_clipboard;
@@ -518,10 +533,12 @@ public View onCreateInputView() {
KeyboardLayoutBuilder builder = new KeyboardLayoutBuilder(this);
builder.setBox(Box.create(0, 0, 1, 1));
- if (mToprow) {
- definitions.addCopyPasteRow(builder);
- } else {
- definitions.addArrowsRow(builder);
+ if (mKeyboardState != R.integer.keyboard_history) {
+ if (mToprow) {
+ definitions.addCopyPasteRow(builder);
+ } else {
+ definitions.addArrowsRow(builder);
+ }
}
if (mKeyboardState == R.integer.keyboard_sym) {
@@ -589,6 +606,11 @@ public View onCreateInputView() {
builder.newRow()
.addKey(sharedPreferences.getPin6())
.addKey(sharedPreferences.getPin7());
+ } else if (mKeyboardState == R.integer.keyboard_history) {
+ // HIST mode: doesn't use KeyboardLayoutView because its coordinates are relative
+ // (every entry would shrink if divided evenly).
+ // Uses ScrollView + LinearLayout with a fixed entry height.
+ return buildHistoryView(mKeyboardUiFactory.theme);
}
Collection keyboardLayout = builder.build();
@@ -607,11 +629,188 @@ public void onUpdateExtractingVisibility(EditorInfo ei) {
super.onUpdateExtractingVisibility(ei);
}
+ /**
+ * Build the view for HIST mode using ScrollView + LinearLayout.
+ * Each entry has a fixed height so it can scroll correctly.
+ */
+ private android.view.View buildHistoryView(com.gazlaws.codeboard.theme.ThemeInfo theme) {
+ android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
+ int screenH = metrics.heightPixels;
+ int screenW = metrics.widthPixels;
+
+ int rowHeightPx = (int)(48 * metrics.density);
+
+ // Get the keyboard height from UiTheme — exactly the same as KeyboardLayoutView.onMeasure
+ // so the HIST height matches the normal keyboard height
+ com.gazlaws.codeboard.theme.UiTheme uiTheme =
+ com.gazlaws.codeboard.theme.UiTheme.buildFromInfo(mKeyboardUiFactory.theme);
+ float kbSize = screenH > screenW ? uiTheme.portraitSize : uiTheme.landscapeSize;
+ int kbHeight = (int)(screenH * kbSize);
+
+ android.widget.LinearLayout root = new android.widget.LinearLayout(this);
+ root.setOrientation(android.widget.LinearLayout.VERTICAL);
+ root.setBackgroundColor(theme.backgroundColor);
+ root.setLayoutParams(new android.view.ViewGroup.LayoutParams(screenW, kbHeight));
+ root.setClipChildren(true);
+ root.setClipToPadding(true);
+
+ // BUG1&2 Fix: use createKeyboardView (same approach as normal mode)
+ // then override onMeasure so its height is only 1 row, not the full keyboard height.
+ // key.box uses relative coordinates 0.0-1.0 — KeyboardLayoutView.layout()
+ // converts them to pixels. Don't populate manually.
+ final int rowH = rowHeightPx;
+ try {
+ com.gazlaws.codeboard.layout.builder.KeyboardLayoutBuilder topBuilder =
+ new com.gazlaws.codeboard.layout.builder.KeyboardLayoutBuilder(this);
+ topBuilder.setBox(com.gazlaws.codeboard.layout.Box.create(0, 0, 1, 1));
+ new com.gazlaws.codeboard.layout.Definitions(this).addHistoryTopRow(topBuilder);
+ java.util.Collection topKeys = topBuilder.build();
+
+ // createKeyboardView produces a KeyboardLayoutView already populated with KeyboardButtonView
+ // at the correct positions and sizes (relative → pixel conversion is done internally)
+ final com.gazlaws.codeboard.layout.ui.KeyboardLayoutView topRowBase =
+ mKeyboardUiFactory.createKeyboardView(this, topKeys);
+
+ // Wrap in a FrameLayout that overrides onMeasure to report a 1-row height
+ // so the parent LinearLayout respects the rowHeightPx height
+ android.widget.FrameLayout topRowWrapper = new android.widget.FrameLayout(this) {
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int w = android.view.View.MeasureSpec.getSize(widthMeasureSpec);
+ setMeasuredDimension(w, rowH);
+ int wSpec = android.view.View.MeasureSpec.makeMeasureSpec(w,
+ android.view.View.MeasureSpec.EXACTLY);
+ int hSpec = android.view.View.MeasureSpec.makeMeasureSpec(rowH,
+ android.view.View.MeasureSpec.EXACTLY);
+ topRowBase.measure(wSpec, hSpec);
+ }
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ topRowBase.layout(l, t, r, b);
+ }
+ };
+ topRowWrapper.addView(topRowBase);
+ topRowWrapper.setLayoutParams(
+ new android.widget.LinearLayout.LayoutParams(screenW, rowHeightPx));
+ root.addView(topRowWrapper);
+ } catch (Exception e) {
+ android.util.Log.e("CodeBoardIME", "Failed to build HIST top row: " + e.getMessage(), e);
+ }
+
+ // Divider below the top row
+ android.view.View topDivider = new android.view.View(this);
+ topDivider.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, 1));
+ topDivider.setBackgroundColor(theme.foregroundColor & 0x33FFFFFF);
+ root.addView(topDivider);
+
+ // ScrollView — explicit height: remainder after top row + divider (1px)
+ int scrollHeight = kbHeight - rowHeightPx - 1;
+ android.widget.ScrollView scrollView = new android.widget.ScrollView(this);
+ scrollView.setLayoutParams(new android.widget.LinearLayout.LayoutParams(screenW, scrollHeight));
+ scrollView.setBackgroundColor(theme.backgroundColor);
+ scrollView.setFillViewport(false);
+
+ final android.widget.LinearLayout entryList = new android.widget.LinearLayout(this);
+ entryList.setOrientation(android.widget.LinearLayout.VERTICAL);
+
+ // Show temporary loading state
+ android.widget.TextView loading = new android.widget.TextView(this);
+ loading.setText("Loading...");
+ loading.setTextColor(theme.foregroundColor);
+ loading.setGravity(android.view.Gravity.CENTER);
+ loading.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, rowHeightPx));
+ entryList.addView(loading);
+
+ // BUG8 Fix: disk I/O on a background thread to avoid ANR
+ final com.gazlaws.codeboard.theme.ThemeInfo themeFinal = theme;
+ final int rowHeightFinal = rowHeightPx;
+ final float densityFinal = metrics.density;
+ final android.os.Handler mainHandler =
+ new android.os.Handler(android.os.Looper.getMainLooper());
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ ClipboardStorage histStorage = new ClipboardStorage(CodeBoardIME.this);
+ final java.util.List entries = histStorage.readRecentEntries(200);
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ entryList.removeAllViews();
+ if (entries.isEmpty()) {
+ android.widget.TextView empty = new android.widget.TextView(CodeBoardIME.this);
+ empty.setText("No history");
+ empty.setTextColor(themeFinal.foregroundColor);
+ empty.setGravity(android.view.Gravity.CENTER);
+ empty.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, rowHeightFinal));
+ entryList.addView(empty);
+ } else {
+ for (final ClipboardEntry entry : entries) {
+ android.widget.TextView tv = new android.widget.TextView(CodeBoardIME.this);
+ tv.setText(entry.text);
+ tv.setTextColor(themeFinal.foregroundColor);
+ tv.setMaxLines(1);
+ tv.setEllipsize(android.text.TextUtils.TruncateAt.END);
+ tv.setPadding((int)(12 * densityFinal), 0, (int)(12 * densityFinal), 0);
+ tv.setGravity(android.view.Gravity.CENTER_VERTICAL);
+ tv.setBackgroundColor(themeFinal.backgroundColor);
+ tv.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, rowHeightFinal));
+ tv.setOnClickListener(new android.view.View.OnClickListener() {
+ private long lastClick = 0;
+ @Override
+ public void onClick(android.view.View v) {
+ long now = System.currentTimeMillis();
+ if (now - lastClick < 400) {
+ android.view.inputmethod.InputConnection ic = getCurrentInputConnection();
+ if (ic != null) ic.commitText(entry.text, 1);
+ lastClick = 0;
+ } else {
+ lastClick = now;
+ }
+ }
+ });
+ entryList.addView(tv);
+ android.view.View divider = new android.view.View(CodeBoardIME.this);
+ divider.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, 1));
+ divider.setBackgroundColor(themeFinal.foregroundColor & 0x33FFFFFF);
+ entryList.addView(divider);
+ }
+ }
+ }
+ });
+ }
+ }).start();
+
+ scrollView.addView(entryList);
+ root.addView(scrollView);
+ return root;
+ }
+
@Override
public void onStartInputView(EditorInfo attribute, boolean restarting) {
super.onStartInputView(attribute, restarting);
setInputView(onCreateInputView());
sEditorInfo = attribute;
+
+ // Start the clipboard monitor when the keyboard is active
+ if (clipboardMonitor == null) {
+ ClipboardPrefs clipboardPrefs = new ClipboardPrefs(this);
+ clipboardMonitor = new ClipboardMonitor(this, clipboardPrefs);
+ }
+ clipboardMonitor.start();
+ }
+
+ @Override
+ public void onFinishInputView(boolean finishingInput) {
+ super.onFinishInputView(finishingInput);
+ if (clipboardMonitor != null) {
+ clipboardMonitor.stop();
+ clipboardMonitor = null; // Let GC collect it, executor is already shut down
+ }
}
public void controlKeyUpdateView() {
@@ -802,4 +1001,4 @@ public void attachToken(IBinder token) {
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/SettingsFragment.java b/app/src/main/java/com/gazlaws/codeboard/SettingsFragment.java
index 316ad678..ec54910b 100644
--- a/app/src/main/java/com/gazlaws/codeboard/SettingsFragment.java
+++ b/app/src/main/java/com/gazlaws/codeboard/SettingsFragment.java
@@ -8,13 +8,17 @@
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Color;
+import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.text.InputType;
import android.util.Log;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
+import android.widget.Toast;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.preference.EditTextPreference;
@@ -22,7 +26,13 @@
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceManager;
+import androidx.preference.SwitchPreferenceCompat;
+import com.gazlaws.codeboard.backup.BackupManager;
+import com.gazlaws.codeboard.backup.BackupSerializer;
+import com.gazlaws.codeboard.clipboard.ClipboardExpireManager;
+import com.gazlaws.codeboard.clipboard.ClipboardPrefs;
import com.gazlaws.codeboard.theme.IOnFocusListenable;
import com.gazlaws.codeboard.theme.ThemeDefinitions;
import com.gazlaws.codeboard.theme.ThemeInfo;
@@ -36,11 +46,75 @@
public class SettingsFragment extends PreferenceFragmentCompat implements IOnFocusListenable {
KeyboardPreferences keyboardPreferences;
+ ClipboardPrefs clipboardPrefs;
+ BackupManager backupManager;
+
+ // SAF launchers for the file picker
+ private ActivityResultLauncher exportLauncher;
+ private ActivityResultLauncher importLauncher;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ backupManager = new BackupManager(requireContext());
+
+ // Export: open the save-file dialog
+ exportLauncher = registerForActivityResult(
+ new ActivityResultContracts.CreateDocument("application/json"),
+ new androidx.activity.result.ActivityResultCallback() {
+ @Override
+ public void onActivityResult(Uri uri) {
+ if (uri == null) return;
+ backupManager.exportTo(uri, new BackupManager.Callback() {
+ @Override
+ public void onSuccess(String message) {
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ }
+ @Override
+ public void onFailure(String error) {
+ Toast.makeText(requireContext(),
+ getString(R.string.backup_error_prefix) + error,
+ Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+ }
+ );
+
+ // Import: open the pick-file dialog
+ importLauncher = registerForActivityResult(
+ new ActivityResultContracts.OpenDocument(),
+ new androidx.activity.result.ActivityResultCallback() {
+ @Override
+ public void onActivityResult(Uri uri) {
+ if (uri == null) return;
+ backupManager.importFrom(uri, new BackupManager.Callback() {
+ @Override
+ public void onSuccess(String message) {
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
+ // Recreate the activity so all preference UI
+ // immediately reflects the new values from SharedPreferences
+ requireActivity().recreate();
+ }
+ @Override
+ public void onFailure(String error) {
+ Toast.makeText(requireContext(),
+ getString(R.string.backup_error_prefix) + error,
+ Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+ }
+ );
+ }
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.preferences, rootKey);
keyboardPreferences = new KeyboardPreferences(requireActivity());
+ clipboardPrefs = new ClipboardPrefs(requireActivity());
+
+ setupClipboardHistoryPrefs();
// Declare a new thread to do a preference check
Thread t = new Thread(new Runnable() {
@@ -119,6 +193,12 @@ public boolean onPreferenceTreeClick(Preference preference) {
return false;
}
switch (preference.getKey()) {
+ case "backup_export":
+ exportLauncher.launch(BackupSerializer.generateFileName());
+ break;
+ case "backup_import":
+ importLauncher.launch(new String[]{"application/json", "*/*"});
+ break;
case "change_keyboard":
InputMethodManager imm = (InputMethodManager)
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
@@ -220,6 +300,44 @@ private void setThemeByIndex(int index) {
keyboardPreferences.setFgColor(String.valueOf(themeInfo.foregroundColor));
}
+ private void setupClipboardHistoryPrefs() {
+ // SwitchPreferenceCompat and ListPreference write directly to SharedPreferences
+ // using their own keys. ClipboardPrefs reads from the same keys.
+ // No extra listener is needed to keep values in sync.
+
+ // Only attach a listener to the custom EditTextPreference to validate input
+ EditTextPreference customPref =
+ getPreferenceManager().findPreference("clipboard_history_expire_custom_hours");
+ if (customPref != null) {
+ customPref.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() {
+ @Override
+ public void onBindEditText(@NonNull android.widget.EditText editText) {
+ editText.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
+ }
+ });
+ customPref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ try {
+ long hours = Long.parseLong(newValue.toString().trim());
+ // Clamp to the valid range
+ if (hours < 1) hours = 1;
+ if (hours > 2160) hours = 2160;
+ preference.setSummary(hours + " hours");
+ // Save the clamped value, not the original newValue
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .edit()
+ .putString("clipboard_history_expire_custom_hours", String.valueOf(hours))
+ .apply();
+ return false; // false so the original newValue isn't saved
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ });
+ }
+ }
+
public void openColourPicker(final String key) {
int color = 0;
if (key.equals("bg_colour_picker")) {
diff --git a/app/src/main/java/com/gazlaws/codeboard/backup/BackupData.java b/app/src/main/java/com/gazlaws/codeboard/backup/BackupData.java
new file mode 100644
index 00000000..5d7e9459
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/backup/BackupData.java
@@ -0,0 +1,67 @@
+package com.gazlaws.codeboard.backup;
+
+/**
+ * Data model to be exported and imported.
+ *
+ * Three groups of data:
+ * 1. preferences — keyboard settings (theme, size, sound, etc.)
+ * 2. symbols — 6 rows of custom symbols
+ * 3. pins — 7 pins/snippets in clipboard mode
+ *
+ * All fields are of type String because SharedPreferences stores them as String.
+ * Boolean/int fields are stored as String ("true"/"false", "0"/"1") to stay
+ * consistent and easy to serialize to JSON.
+ */
+public class BackupData {
+
+ public static final String BACKUP_VERSION = "1";
+
+ // -------------------------------------------------------------------------
+ // Metadata
+ // -------------------------------------------------------------------------
+ public String version;
+ public String exportedAt; // ISO 8601 timestamp
+
+ // -------------------------------------------------------------------------
+ // Preferences
+ // -------------------------------------------------------------------------
+ public String sound;
+ public String vibrate;
+ public String vibrateMs;
+ public String fontsize;
+ public String sizePortrait;
+ public String sizeLandscape;
+ public String preview;
+ public String borders;
+ public String navbar;
+ public String navbarDark;
+ public String layout;
+ public String theme;
+ public String customTheme;
+ public String bgColor;
+ public String fgColor;
+ public String topRowActions;
+ public String notification;
+
+ // -------------------------------------------------------------------------
+ // Custom symbols
+ // -------------------------------------------------------------------------
+ public String symbolsMain;
+ public String symbolsMain2;
+ public String symbolsMainBottom;
+ public String symbolsSym;
+ public String symbolsSym2;
+ public String symbolsSym3;
+ public String symbolsSym4;
+
+ // -------------------------------------------------------------------------
+ // Pins
+ // -------------------------------------------------------------------------
+ public String pin1;
+ public String pin2;
+ public String pin3;
+ public String pin4;
+ public String pin5;
+ public String pin6;
+ public String pin7;
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/backup/BackupManager.java b/app/src/main/java/com/gazlaws/codeboard/backup/BackupManager.java
new file mode 100644
index 00000000..117fcb5b
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/backup/BackupManager.java
@@ -0,0 +1,243 @@
+package com.gazlaws.codeboard.backup;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.preference.PreferenceManager;
+
+import org.json.JSONException;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+
+/**
+ * Core logic for exporting and importing backups.
+ *
+ * Export:
+ * 1. Read all settings from SharedPreferences via KeyboardPreferences
+ * 2. Package into BackupData
+ * 3. Serialize to JSON via BackupSerializer
+ * 4. Write to the Uri chosen by the user (via SAF — Storage Access Framework)
+ *
+ * Import:
+ * 1. Read the file from the Uri chosen by the user
+ * 2. Parse JSON into BackupData via BackupSerializer
+ * 3. Validate the version
+ * 4. Write all values to SharedPreferences
+ */
+public class BackupManager {
+
+ private static final String TAG = "BackupManager";
+
+ public interface Callback {
+ void onSuccess(String message);
+ void onFailure(String error);
+ }
+
+ private final Context context;
+ private final SharedPreferences prefs;
+
+ public BackupManager(Context context) {
+ this.context = context.getApplicationContext();
+ this.prefs = PreferenceManager.getDefaultSharedPreferences(this.context);
+ }
+
+ // -------------------------------------------------------------------------
+ // Export
+ // -------------------------------------------------------------------------
+
+ /**
+ * Export all settings to a JSON file at the Uri chosen by the user.
+ *
+ * @param uri Uri from the SAF file picker (ACTION_CREATE_DOCUMENT)
+ * @param callback success/failure result
+ */
+ public void exportTo(Uri uri, Callback callback) {
+ try {
+ BackupData data = collectData();
+ String json = BackupSerializer.toJson(data);
+ writeToUri(uri, json);
+ callback.onSuccess("Backup saved successfully");
+ Log.d(TAG, "Export succeeded to: " + uri);
+ } catch (JSONException e) {
+ String msg = "Failed to create JSON: " + e.getMessage();
+ Log.e(TAG, msg, e);
+ callback.onFailure(msg);
+ } catch (IOException e) {
+ String msg = "Failed to write file: " + e.getMessage();
+ Log.e(TAG, msg, e);
+ callback.onFailure(msg);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Import
+ // -------------------------------------------------------------------------
+
+ /**
+ * Import settings from a JSON file at the Uri chosen by the user.
+ *
+ * @param uri Uri from the SAF file picker (ACTION_OPEN_DOCUMENT)
+ * @param callback success/failure result
+ */
+ public void importFrom(Uri uri, Callback callback) {
+ try {
+ String json = readFromUri(uri);
+ BackupData data = BackupSerializer.fromJson(json);
+ applyData(data);
+ callback.onSuccess("Backup restored successfully");
+ Log.d(TAG, "Import succeeded from: " + uri);
+ } catch (JSONException e) {
+ String msg = "Backup file is invalid or corrupted: " + e.getMessage();
+ Log.e(TAG, msg, e);
+ callback.onFailure(msg);
+ } catch (IOException e) {
+ String msg = "Failed to read file: " + e.getMessage();
+ Log.e(TAG, msg, e);
+ callback.onFailure(msg);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Collect data from SharedPreferences
+ // -------------------------------------------------------------------------
+
+ private BackupData collectData() {
+ BackupData data = new BackupData();
+
+ data.version = BackupData.BACKUP_VERSION;
+ data.exportedAt = BackupSerializer.generateTimestamp();
+
+ // Preferences — read directly from SharedPreferences using the same keys
+ data.sound = String.valueOf(prefs.getBoolean("sound", true));
+ data.vibrate = String.valueOf(prefs.getBoolean("vibrate", true));
+ data.vibrateMs = prefs.getString("vibrate_ms", "30");
+ data.fontsize = prefs.getString("font_size", "14");
+ data.sizePortrait = prefs.getString("size_portrait", "40");
+ data.sizeLandscape = prefs.getString("size_landscape", "40");
+ data.preview = String.valueOf(prefs.getBoolean("preview", false));
+ data.borders = String.valueOf(prefs.getBoolean("borders", true));
+ data.navbar = String.valueOf(prefs.getBoolean("navbar", false));
+ data.navbarDark = String.valueOf(prefs.getBoolean("navbar_dark", false));
+ data.layout = prefs.getString("layout", "0");
+ data.theme = prefs.getString("theme", "0");
+ data.customTheme = String.valueOf(prefs.getBoolean("custom_theme", false));
+ data.bgColor = prefs.getString("bg_colour_picker", "");
+ data.fgColor = prefs.getString("fg_colour_picker", "");
+ data.topRowActions = String.valueOf(prefs.getBoolean("top_row_actions", false));
+ data.notification = String.valueOf(prefs.getBoolean("notification", false));
+
+ // Custom symbols
+ data.symbolsMain = prefs.getString("input_symbols_main", "");
+ data.symbolsMain2 = prefs.getString("input_symbols_main_2", "");
+ data.symbolsMainBottom = prefs.getString("input_symbols_main_bottom", "");
+ data.symbolsSym = prefs.getString("input_symbols_sym", "");
+ data.symbolsSym2 = prefs.getString("input_symbols_sym_2", "");
+ data.symbolsSym3 = prefs.getString("input_symbols_sym_3", "");
+ data.symbolsSym4 = prefs.getString("input_symbols_sym_4", "");
+
+ // Pins
+ data.pin1 = prefs.getString("pin1", "");
+ data.pin2 = prefs.getString("pin2", "");
+ data.pin3 = prefs.getString("pin3", "");
+ data.pin4 = prefs.getString("pin4", "");
+ data.pin5 = prefs.getString("pin5", "");
+ data.pin6 = prefs.getString("pin6", "");
+ data.pin7 = prefs.getString("pin7", "");
+
+ return data;
+ }
+
+ // -------------------------------------------------------------------------
+ // Apply data to SharedPreferences
+ // -------------------------------------------------------------------------
+
+ private void applyData(BackupData data) {
+ SharedPreferences.Editor editor = prefs.edit();
+
+ // Preferences
+ applyBool(editor, "sound", data.sound);
+ applyBool(editor, "vibrate", data.vibrate);
+ applyStr(editor, "vibrate_ms", data.vibrateMs);
+ applyStr(editor, "font_size", data.fontsize);
+ applyStr(editor, "size_portrait", data.sizePortrait);
+ applyStr(editor, "size_landscape", data.sizeLandscape);
+ applyBool(editor, "preview", data.preview);
+ applyBool(editor, "borders", data.borders);
+ applyBool(editor, "navbar", data.navbar);
+ applyBool(editor, "navbar_dark", data.navbarDark);
+ applyStr(editor, "layout", data.layout);
+ applyStr(editor, "theme", data.theme);
+ applyBool(editor, "custom_theme", data.customTheme);
+ applyStr(editor, "bg_colour_picker", data.bgColor);
+ applyStr(editor, "fg_colour_picker", data.fgColor);
+ applyBool(editor, "top_row_actions", data.topRowActions);
+ applyBool(editor, "notification", data.notification);
+
+ // Custom symbols
+ applyStr(editor, "input_symbols_main", data.symbolsMain);
+ applyStr(editor, "input_symbols_main_2", data.symbolsMain2);
+ applyStr(editor, "input_symbols_main_bottom", data.symbolsMainBottom);
+ applyStr(editor, "input_symbols_sym", data.symbolsSym);
+ applyStr(editor, "input_symbols_sym_2", data.symbolsSym2);
+ applyStr(editor, "input_symbols_sym_3", data.symbolsSym3);
+ applyStr(editor, "input_symbols_sym_4", data.symbolsSym4);
+
+ // Pins
+ applyStr(editor, "pin1", data.pin1);
+ applyStr(editor, "pin2", data.pin2);
+ applyStr(editor, "pin3", data.pin3);
+ applyStr(editor, "pin4", data.pin4);
+ applyStr(editor, "pin5", data.pin5);
+ applyStr(editor, "pin6", data.pin6);
+ applyStr(editor, "pin7", data.pin7);
+
+ editor.apply();
+ }
+
+ /** Write string to prefs only if it is not null and not empty. */
+ private void applyStr(SharedPreferences.Editor editor, String key, String value) {
+ if (value != null && !value.isEmpty()) {
+ editor.putString(key, value);
+ }
+ }
+
+ /** Write boolean to prefs only if it is not null and not empty. */
+ private void applyBool(SharedPreferences.Editor editor, String key, String value) {
+ if (value != null && !value.isEmpty()) {
+ editor.putBoolean(key, Boolean.parseBoolean(value));
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // File I/O via SAF Uri
+ // -------------------------------------------------------------------------
+
+ private void writeToUri(Uri uri, String content) throws IOException {
+ OutputStream os = context.getContentResolver().openOutputStream(uri, "wt");
+ if (os == null) throw new IOException("Cannot open output stream for: " + uri);
+ try (Writer writer = new OutputStreamWriter(os, "UTF-8")) {
+ writer.write(content);
+ }
+ }
+
+ private String readFromUri(Uri uri) throws IOException {
+ InputStream is = context.getContentResolver().openInputStream(uri);
+ if (is == null) throw new IOException("Cannot open input stream for: " + uri);
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append('\n');
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/backup/BackupSerializer.java b/app/src/main/java/com/gazlaws/codeboard/backup/BackupSerializer.java
new file mode 100644
index 00000000..adaeed2d
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/backup/BackupSerializer.java
@@ -0,0 +1,175 @@
+package com.gazlaws.codeboard.backup;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Serialization and deserialization of BackupData to/from JSON.
+ *
+ * Uses org.json which is already available on Android (no extra library needed).
+ *
+ * Format JSON:
+ * {
+ * "version": "1",
+ * "exportedAt": "2026-06-28T12:00:00+08:00",
+ * "preferences": {
+ * "sound": "true",
+ * "vibrate": "true",
+ * ...
+ * },
+ * "symbols": {
+ * "main": "...",
+ * "main2": "...",
+ * ...
+ * },
+ * "pins": {
+ * "pin1": "...",
+ * ...
+ * }
+ * }
+ */
+public class BackupSerializer {
+
+ // -------------------------------------------------------------------------
+ // Serialize: BackupData → JSON string
+ // -------------------------------------------------------------------------
+
+ public static String toJson(BackupData data) throws JSONException {
+ JSONObject root = new JSONObject();
+
+ root.put("version", data.version);
+ root.put("exportedAt", data.exportedAt);
+
+ // Preferences
+ JSONObject prefs = new JSONObject();
+ prefs.put("sound", nullSafe(data.sound));
+ prefs.put("vibrate", nullSafe(data.vibrate));
+ prefs.put("vibrateMs", nullSafe(data.vibrateMs));
+ prefs.put("fontSize", nullSafe(data.fontsize));
+ prefs.put("sizePortrait", nullSafe(data.sizePortrait));
+ prefs.put("sizeLandscape", nullSafe(data.sizeLandscape));
+ prefs.put("preview", nullSafe(data.preview));
+ prefs.put("borders", nullSafe(data.borders));
+ prefs.put("navbar", nullSafe(data.navbar));
+ prefs.put("navbarDark", nullSafe(data.navbarDark));
+ prefs.put("layout", nullSafe(data.layout));
+ prefs.put("theme", nullSafe(data.theme));
+ prefs.put("customTheme", nullSafe(data.customTheme));
+ prefs.put("bgColor", nullSafe(data.bgColor));
+ prefs.put("fgColor", nullSafe(data.fgColor));
+ prefs.put("topRowActions", nullSafe(data.topRowActions));
+ prefs.put("notification", nullSafe(data.notification));
+ root.put("preferences", prefs);
+
+ // Symbols
+ JSONObject symbols = new JSONObject();
+ symbols.put("main", nullSafe(data.symbolsMain));
+ symbols.put("main2", nullSafe(data.symbolsMain2));
+ symbols.put("mainBottom", nullSafe(data.symbolsMainBottom));
+ symbols.put("sym", nullSafe(data.symbolsSym));
+ symbols.put("sym2", nullSafe(data.symbolsSym2));
+ symbols.put("sym3", nullSafe(data.symbolsSym3));
+ symbols.put("sym4", nullSafe(data.symbolsSym4));
+ root.put("symbols", symbols);
+
+ // Pins
+ JSONObject pins = new JSONObject();
+ pins.put("pin1", nullSafe(data.pin1));
+ pins.put("pin2", nullSafe(data.pin2));
+ pins.put("pin3", nullSafe(data.pin3));
+ pins.put("pin4", nullSafe(data.pin4));
+ pins.put("pin5", nullSafe(data.pin5));
+ pins.put("pin6", nullSafe(data.pin6));
+ pins.put("pin7", nullSafe(data.pin7));
+ root.put("pins", pins);
+
+ return root.toString(2); // indent of 2 spaces for readability
+ }
+
+ // -------------------------------------------------------------------------
+ // Deserialize: JSON string → BackupData
+ // -------------------------------------------------------------------------
+
+ public static BackupData fromJson(String json) throws JSONException {
+ JSONObject root = new JSONObject(json);
+
+ BackupData data = new BackupData();
+ data.version = root.optString("version", "1");
+ data.exportedAt = root.optString("exportedAt", "");
+
+ // Preferences
+ JSONObject prefs = root.optJSONObject("preferences");
+ if (prefs != null) {
+ data.sound = prefs.optString("sound", "");
+ data.vibrate = prefs.optString("vibrate", "");
+ data.vibrateMs = prefs.optString("vibrateMs", "");
+ data.fontsize = prefs.optString("fontSize", "");
+ data.sizePortrait = prefs.optString("sizePortrait", "");
+ data.sizeLandscape = prefs.optString("sizeLandscape", "");
+ data.preview = prefs.optString("preview", "");
+ data.borders = prefs.optString("borders", "");
+ data.navbar = prefs.optString("navbar", "");
+ data.navbarDark = prefs.optString("navbarDark", "");
+ data.layout = prefs.optString("layout", "0");
+ data.theme = prefs.optString("theme", "0");
+ data.customTheme = prefs.optString("customTheme", "false");
+ data.bgColor = prefs.optString("bgColor", "");
+ data.fgColor = prefs.optString("fgColor", "");
+ data.topRowActions = prefs.optString("topRowActions", "");
+ data.notification = prefs.optString("notification", "");
+ }
+
+ // Symbols
+ JSONObject symbols = root.optJSONObject("symbols");
+ if (symbols != null) {
+ data.symbolsMain = symbols.optString("main", "");
+ data.symbolsMain2 = symbols.optString("main2", "");
+ data.symbolsMainBottom = symbols.optString("mainBottom", "");
+ data.symbolsSym = symbols.optString("sym", "");
+ data.symbolsSym2 = symbols.optString("sym2", "");
+ data.symbolsSym3 = symbols.optString("sym3", "");
+ data.symbolsSym4 = symbols.optString("sym4", "");
+ }
+
+ // Pins
+ JSONObject pins = root.optJSONObject("pins");
+ if (pins != null) {
+ data.pin1 = pins.optString("pin1", "");
+ data.pin2 = pins.optString("pin2", "");
+ data.pin3 = pins.optString("pin3", "");
+ data.pin4 = pins.optString("pin4", "");
+ data.pin5 = pins.optString("pin5", "");
+ data.pin6 = pins.optString("pin6", "");
+ data.pin7 = pins.optString("pin7", "");
+ }
+
+ return data;
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper
+ // -------------------------------------------------------------------------
+
+ public static String generateTimestamp() {
+ // ISO 8601 format with a timezone offset in +HH:MM format
+ java.text.SimpleDateFormat sdf =
+ new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ENGLISH);
+ sdf.setTimeZone(TimeZone.getDefault());
+ return sdf.format(new Date());
+ }
+
+ public static String generateFileName() {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH);
+ sdf.setTimeZone(TimeZone.getDefault());
+ return "codeboard_backup_" + sdf.format(new Date()) + ".json";
+ }
+
+ private static String nullSafe(String s) {
+ return s != null ? s : "";
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardBootReceiver.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardBootReceiver.java
new file mode 100644
index 00000000..404f83cd
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardBootReceiver.java
@@ -0,0 +1,41 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/**
+ * BroadcastReceiver that listens for ACTION_BOOT_COMPLETED.
+ *
+ * Responsibilities:
+ * - If expire is set to "on reboot", delete all clipboard history files.
+ * - If expire is not reboot-based, still run normal cleanup
+ * (e.g. files from 3 days ago when the setting is 1 day).
+ *
+ * Registered in AndroidManifest.xml with:
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+public class ClipboardBootReceiver extends BroadcastReceiver {
+
+ private static final String TAG = "ClipboardBootReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return;
+
+ Log.d(TAG, "Boot completed, running clipboard history cleanup...");
+
+ ClipboardPrefs prefs = new ClipboardPrefs(context);
+ ClipboardStorage store = new ClipboardStorage(context);
+
+ // Use static method — no need to instantiate ClipboardMonitor
+ ClipboardMonitor.cleanupOnReboot(store, prefs);
+ Log.d(TAG, "Cleanup on reboot finished.");
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardEntry.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardEntry.java
new file mode 100644
index 00000000..5959c52a
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardEntry.java
@@ -0,0 +1,146 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.util.Base64;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.UUID;
+
+/**
+ * Data model for a single clipboard entry.
+ *
+ * Every time the user copies text, one ClipboardEntry is created.
+ * This entry stores:
+ * - id : short unique identifier (8 characters from a UUID)
+ * - text : the original copied text
+ * - timestamp: time it was copied (epoch millis)
+ *
+ * Format when written to file:
+ *
+ * === {id} ===
+ * TIMESTAMP: {timestamp human-readable}
+ * {text base64-encoded}
+ * === END {id} ===
+ */
+public class ClipboardEntry {
+
+ private static final String TIMESTAMP_FORMAT = "EEE MMM dd HH:mm:ss z yyyy";
+
+ public final String id;
+ public final String text;
+ public final long timestamp;
+
+ public ClipboardEntry(String text, long timestamp) {
+ this.id = generateShortId();
+ this.text = text;
+ this.timestamp = timestamp;
+ }
+
+ // Internal constructor for deserialize — preserves the ID from the file
+ private ClipboardEntry(String id, String text, long timestamp) {
+ this.id = id;
+ this.text = text;
+ this.timestamp = timestamp;
+ }
+
+ // -------------------------------------------------------------------------
+ // Serialize → string (to be written to file)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Convert this entry into a text block ready to be written to file.
+ * The text content is Base64-encoded so it is safe for any character.
+ */
+ public String serialize() throws java.io.UnsupportedEncodingException {
+ String encoded = Base64.encodeToString(text.getBytes("UTF-8"), Base64.NO_WRAP);
+ String tsHuman = formatTimestamp(timestamp);
+
+ return "=== " + id + " ===\n"
+ + "TIMESTAMP: " + tsHuman + "\n"
+ + encoded + "\n"
+ + "=== END " + id + " ===\n";
+ }
+
+ // -------------------------------------------------------------------------
+ // Deserialize ← string (to be read from file)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Parse a single entry block from a string.
+ * Returns null if the format is invalid.
+ *
+ * Expected format:
+ * === {id} ===
+ * TIMESTAMP: {ts}
+ * {base64}
+ * === END {id} ===
+ */
+ public static ClipboardEntry deserialize(String block) {
+ try {
+ String[] lines = block.trim().split("\n");
+ if (lines.length < 4) return null;
+
+ // Line 0: === {id} ===
+ String headerLine = lines[0].trim();
+ if (!headerLine.startsWith("=== ") || !headerLine.endsWith(" ===")) return null;
+ // Strip the leading "=== " and trailing " ==="
+ String id = headerLine.substring(4, headerLine.length() - 4).trim();
+ if (id.isEmpty()) return null;
+
+ // Find the TIMESTAMP: line (no fixed index assumed, search from line 1)
+ String tsStr = null;
+ int encodedLineIdx = -1;
+ for (int i = 1; i < lines.length; i++) {
+ String line = lines[i].trim();
+ if (line.startsWith("TIMESTAMP:") && tsStr == null) {
+ tsStr = line.substring("TIMESTAMP:".length()).trim();
+ } else if (tsStr != null && !line.startsWith("=== END") && !line.isEmpty()) {
+ encodedLineIdx = i;
+ break;
+ }
+ }
+ if (tsStr == null || encodedLineIdx < 0) return null;
+
+ long ts = parseTimestamp(tsStr);
+
+ // Decode Base64 with an explicit charset
+ String encoded = lines[encodedLineIdx].trim();
+ byte[] decoded = Base64.decode(encoded, Base64.NO_WRAP);
+ String text = new String(decoded, "UTF-8");
+
+ return new ClipboardEntry(id, text, ts);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static String generateShortId() {
+ // Take the first 8 characters of a UUID, without hyphens
+ // Example: "7f29c1e8"
+ String uuid = UUID.randomUUID().toString().replace("-", "");
+ return uuid.substring(0, 6) + "-" + uuid.substring(6, 8);
+ }
+
+ public static String formatTimestamp(long epochMillis) {
+ SimpleDateFormat sdf = new SimpleDateFormat(TIMESTAMP_FORMAT, Locale.ENGLISH);
+ sdf.setTimeZone(TimeZone.getDefault());
+ return sdf.format(new Date(epochMillis));
+ }
+
+ private static long parseTimestamp(String tsStr) {
+ try {
+ SimpleDateFormat sdf = new SimpleDateFormat(TIMESTAMP_FORMAT, Locale.ENGLISH);
+ sdf.setTimeZone(TimeZone.getDefault());
+ Date d = sdf.parse(tsStr);
+ return d != null ? d.getTime() : System.currentTimeMillis();
+ } catch (Exception e) {
+ return System.currentTimeMillis();
+ }
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardExpireManager.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardExpireManager.java
new file mode 100644
index 00000000..20db41c1
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardExpireManager.java
@@ -0,0 +1,184 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.util.Log;
+
+import java.io.File;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Manages expiration and cleanup of clipboard history files.
+ *
+ * Rules:
+ * - Each clipboard_log.txt file represents one day.
+ * - A file is considered expired if (current time - file date) > expire duration.
+ * - Expired files are automatically deleted when cleanup runs.
+ *
+ * Available duration options:
+ * - 1 hour, 8 hours, 1 day, 2 days, 1 week, 30 days
+ * - On reboot (handled in ClipboardMonitor via boot receiver)
+ * - Custom: 1 hour up to 90 days
+ */
+public class ClipboardExpireManager {
+
+ private static final String TAG = "ClipboardExpireManager";
+
+ // -------------------------------------------------------------------------
+ // Duration constants (in milliseconds)
+ // -------------------------------------------------------------------------
+
+ public static final long EXPIRE_1_HOUR = TimeUnit.HOURS.toMillis(1);
+ public static final long EXPIRE_8_HOURS = TimeUnit.HOURS.toMillis(8);
+ public static final long EXPIRE_1_DAY = TimeUnit.DAYS.toMillis(1);
+ public static final long EXPIRE_2_DAYS = TimeUnit.DAYS.toMillis(2);
+ public static final long EXPIRE_1_WEEK = TimeUnit.DAYS.toMillis(7);
+ public static final long EXPIRE_30_DAYS = TimeUnit.DAYS.toMillis(30);
+ public static final long EXPIRE_ON_REBOOT = -1L; // special flag
+
+ public static final long EXPIRE_MIN_MS = TimeUnit.HOURS.toMillis(1);
+ public static final long EXPIRE_MAX_MS = TimeUnit.DAYS.toMillis(90);
+
+ // -------------------------------------------------------------------------
+ // Cleanup
+ // -------------------------------------------------------------------------
+
+ /**
+ * Delete all files that have passed the expire duration.
+ *
+ * @param rootDir root directory .localcache/copy
+ * @param expireMillis expire duration in ms. If EXPIRE_ON_REBOOT, delete everything.
+ */
+ public static void cleanup(File rootDir, long expireMillis) {
+ if (rootDir == null || !rootDir.exists()) return;
+
+ long now = System.currentTimeMillis();
+ long cutoffTime = (expireMillis == EXPIRE_ON_REBOOT) ? now : now - expireMillis;
+
+ Log.d(TAG, "Starting cleanup. Expire=" + expireMillis + "ms, cutoff=" + cutoffTime);
+ deleteExpiredFiles(rootDir, cutoffTime);
+ deleteEmptyDirs(rootDir);
+ }
+
+ /**
+ * Cleanup on reboot — delete all files.
+ */
+ public static void cleanupOnReboot(File rootDir) {
+ cleanup(rootDir, EXPIRE_ON_REBOOT);
+ }
+
+ // -------------------------------------------------------------------------
+ // Validate custom duration
+ // -------------------------------------------------------------------------
+
+ /**
+ * Validate a custom duration from the user.
+ * Minimum 1 hour, maximum 90 days.
+ *
+ * @param millis duration in ms
+ * @return duration clamped to the valid range
+ */
+ public static long validateCustomDuration(long millis) {
+ if (millis < EXPIRE_MIN_MS) return EXPIRE_MIN_MS;
+ if (millis > EXPIRE_MAX_MS) return EXPIRE_MAX_MS;
+ return millis;
+ }
+
+ /**
+ * Human-readable label for the expire duration.
+ */
+ public static String getLabel(long expireMillis) {
+ if (expireMillis == EXPIRE_ON_REBOOT) return "On reboot";
+ if (expireMillis == EXPIRE_1_HOUR) return "1 hour";
+ if (expireMillis == EXPIRE_8_HOURS) return "8 hours";
+ if (expireMillis == EXPIRE_1_DAY) return "1 day";
+ if (expireMillis == EXPIRE_2_DAYS) return "2 days";
+ if (expireMillis == EXPIRE_1_WEEK) return "1 week";
+ if (expireMillis == EXPIRE_30_DAYS) return "30 days";
+
+ // Custom
+ long hours = TimeUnit.MILLISECONDS.toHours(expireMillis);
+ long days = TimeUnit.MILLISECONDS.toDays(expireMillis);
+ if (days > 0) return days + " days (custom)";
+ return hours + " hours (custom)";
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Recursively delete files whose lastModified < cutoffTime.
+ * Only deletes files named "clipboard_log.txt".
+ */
+ private static void deleteExpiredFiles(File dir, long cutoffTime) {
+ File[] children = dir.listFiles();
+ if (children == null) return;
+
+ for (File child : children) {
+ if (child.isDirectory()) {
+ deleteExpiredFiles(child, cutoffTime);
+ } else if (child.getName().equals("clipboard_log.txt")) {
+ // Use the date from the folder structure (year/Month/day)
+ // instead of lastModified(), which can change when the file is updated
+ long fileDate = parseDateFromPath(child);
+ long dateToCheck = fileDate > 0 ? fileDate : child.lastModified();
+ if (dateToCheck < cutoffTime) {
+ boolean deleted = child.delete();
+ Log.d(TAG, (deleted ? "Deleted: " : "Failed to delete: ") + child.getPath());
+ }
+ }
+ }
+ }
+
+ /**
+ * Parse the date from the folder path: .../year/Month/day/clipboard_log.txt
+ * Example: .../2026/June/29/clipboard_log.txt → epoch millis for 2026-06-29
+ * Returns -1 if it cannot be parsed.
+ */
+ private static long parseDateFromPath(File file) {
+ try {
+ // parent = day dir, parent.parent = month dir, parent.parent.parent = year dir
+ File dayDir = file.getParentFile();
+ File monthDir = dayDir != null ? dayDir.getParentFile() : null;
+ File yearDir = monthDir != null ? monthDir.getParentFile() : null;
+ if (dayDir == null || monthDir == null || yearDir == null) return -1;
+
+ int day = Integer.parseInt(dayDir.getName().trim());
+ int year = Integer.parseInt(yearDir.getName().trim());
+ // Parse the month name using SimpleDateFormat
+ java.text.SimpleDateFormat sdf =
+ new java.text.SimpleDateFormat("MMMM", java.util.Locale.ENGLISH);
+ java.util.Date monthDate = sdf.parse(monthDir.getName().trim());
+ if (monthDate == null) return -1;
+
+ java.util.Calendar cal = java.util.Calendar.getInstance();
+ cal.setTime(monthDate);
+ int month = cal.get(java.util.Calendar.MONTH); // 0-based
+
+ cal.set(year, month, day, 23, 59, 59);
+ cal.set(java.util.Calendar.MILLISECOND, 999);
+ return cal.getTimeInMillis();
+ } catch (Exception e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Recursively delete empty directories from the inside out.
+ */
+ private static void deleteEmptyDirs(File dir) {
+ File[] children = dir.listFiles();
+ if (children == null) return;
+
+ for (File child : children) {
+ if (child.isDirectory()) {
+ deleteEmptyDirs(child);
+ // Check again after recursion
+ File[] remaining = child.listFiles();
+ if (remaining != null && remaining.length == 0) {
+ child.delete();
+ Log.d(TAG, "Empty directory deleted: " + child.getPath());
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardMonitor.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardMonitor.java
new file mode 100644
index 00000000..76386d1e
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardMonitor.java
@@ -0,0 +1,181 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.util.Log;
+
+import java.io.File;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Main orchestrator for the clipboard history feature.
+ *
+ * Responsibilities:
+ * 1. Listen for clipboard changes via ClipboardManager.OnPrimaryClipChangedListener
+ * 2. Read newly copied text
+ * 3. Save it to file via ClipboardStorage
+ * 4. Run cleanup of expired entries via ClipboardExpireManager
+ *
+ * Usage:
+ * ClipboardMonitor monitor = new ClipboardMonitor(context, prefs);
+ * monitor.start(); // start listening (call when keyboard is active)
+ * monitor.stop(); // stop listening (call when keyboard is inactive)
+ */
+public class ClipboardMonitor {
+
+ private static final String TAG = "ClipboardMonitor";
+
+ private final Context context;
+ private final ClipboardStorage storage;
+ private final ClipboardPrefs prefs;
+ private final ExecutorService executor;
+
+ private ClipboardManager clipboardManager;
+ private ClipboardManager.OnPrimaryClipChangedListener listener;
+
+ private boolean isRunning = false;
+
+ public ClipboardMonitor(Context context, ClipboardPrefs prefs) {
+ this.context = context.getApplicationContext();
+ this.prefs = prefs;
+ this.storage = new ClipboardStorage(context);
+ this.executor = Executors.newSingleThreadExecutor();
+ }
+
+ // -------------------------------------------------------------------------
+ // Lifecycle
+ // -------------------------------------------------------------------------
+
+ /**
+ * Start listening for clipboard changes.
+ * Also runs expired file cleanup in the background.
+ */
+ public void start() {
+ if (!prefs.isEnabled()) {
+ Log.d(TAG, "Clipboard history is disabled, skipping start.");
+ return;
+ }
+ if (isRunning) return;
+ isRunning = true;
+
+ setupListener();
+ runCleanupAsync();
+ }
+
+ private void setupListener() {
+ clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboardManager == null) {
+ Log.e(TAG, "ClipboardManager is not available.");
+ return;
+ }
+
+ listener = new ClipboardManager.OnPrimaryClipChangedListener() {
+ @Override
+ public void onPrimaryClipChanged() {
+ handleClipboardChange();
+ }
+ };
+
+ clipboardManager.addPrimaryClipChangedListener(listener);
+ Log.d(TAG, "ClipboardMonitor started.");
+ }
+
+ /**
+ * Stop listening for clipboard changes.
+ */
+ public void stop() {
+ if (!isRunning) return;
+ isRunning = false;
+
+ if (clipboardManager != null && listener != null) {
+ clipboardManager.removePrimaryClipChangedListener(listener);
+ }
+
+ // Shut down the executor to avoid leaking a thread when the keyboard closes
+ executor.shutdown();
+ Log.d(TAG, "ClipboardMonitor stopped.");
+ }
+
+ /**
+ * Run cleanup when the device reboots.
+ * Called from BootReceiver — no executor needed because BootReceiver
+ * already runs on a separate background thread.
+ */
+ public static void cleanupOnReboot(ClipboardStorage storage, ClipboardPrefs prefs) {
+ long expireMillis = prefs.getExpireMillis();
+ if (expireMillis == ClipboardExpireManager.EXPIRE_ON_REBOOT) {
+ ClipboardExpireManager.cleanupOnReboot(storage.getRootDir());
+ } else {
+ ClipboardExpireManager.cleanup(storage.getRootDir(), expireMillis);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Core logic
+ // -------------------------------------------------------------------------
+
+ private void handleClipboardChange() {
+ try {
+ if (clipboardManager == null || !clipboardManager.hasPrimaryClip()) return;
+
+ ClipData clip = clipboardManager.getPrimaryClip();
+ if (clip == null || clip.getItemCount() == 0) return;
+
+ CharSequence raw = clip.getItemAt(0).getText();
+ if (raw == null) return;
+
+ final String text = raw.toString().trim();
+ if (text.isEmpty()) return;
+
+ // Save or update the timestamp on a background thread
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ saveOrUpdate(text);
+ }
+ });
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error while handling clipboard: " + e.getMessage(), e);
+ }
+ }
+
+ private void saveOrUpdate(String text) {
+ long now = System.currentTimeMillis();
+ File todayFile = storage.getTodayFile(now);
+
+ // 1. Check today's file first
+ boolean updatedToday = storage.updateTimestampIfExists(text, now);
+ if (updatedToday) {
+ Log.d(TAG, "Timestamp updated in today's file.");
+ return;
+ }
+
+ // 2. Check files from previous days
+ boolean foundInOld = storage.findAndRemoveFromOldFiles(text, todayFile);
+ if (foundInOld) {
+ // Found and removed from an old file → append to today's file
+ Log.d(TAG, "Entry moved from an old file to today's file.");
+ }
+
+ // 3. Append to today's file (new entry or moved entry)
+ ClipboardEntry entry = new ClipboardEntry(text, now);
+ boolean ok = storage.append(entry);
+ if (ok) {
+ Log.d(TAG, "Entry saved in today's file: id=" + entry.id);
+ }
+ }
+
+ private void runCleanupAsync() {
+ final long expireMillis = prefs.getExpireMillis();
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ Log.d(TAG, "Cleanup async, expire=" + ClipboardExpireManager.getLabel(expireMillis));
+ ClipboardExpireManager.cleanup(storage.getRootDir(), expireMillis);
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardPrefs.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardPrefs.java
new file mode 100644
index 00000000..d0f31c0e
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardPrefs.java
@@ -0,0 +1,98 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.preference.PreferenceManager;
+
+/**
+ * SharedPreferences wrapper for clipboard history feature settings.
+ *
+ * Keys managed:
+ * clipboard_history_enabled : boolean — whether the feature is active
+ * (same key as SwitchPreferenceCompat in preferences.xml)
+ * clipboard_history_expire_preset : String — duration in HOURS, "-1" = on reboot, "0" = custom
+ * (same key as ListPreference in preferences.xml)
+ * clipboard_history_expire_custom_hours : String — custom hours from EditTextPreference
+ */
+public class ClipboardPrefs {
+
+ // Keys must exactly match those in preferences.xml
+ private static final String KEY_ENABLED = "clipboard_history_enabled";
+ private static final String KEY_EXPIRE_PRESET = "clipboard_history_expire_preset";
+ private static final String KEY_EXPIRE_CUSTOM = "clipboard_history_expire_custom_hours";
+
+ // Default: feature active, expire after 1 day (24 hours)
+ private static final boolean DEFAULT_ENABLED = true;
+ private static final String DEFAULT_EXPIRE_HOURS = "24";
+
+ private final SharedPreferences prefs;
+
+ public ClipboardPrefs(Context context) {
+ this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
+ }
+
+ // -------------------------------------------------------------------------
+ // Enabled
+ // -------------------------------------------------------------------------
+
+ public boolean isEnabled() {
+ return prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED);
+ }
+
+ public void setEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_ENABLED, enabled).apply();
+ }
+
+ // -------------------------------------------------------------------------
+ // Expire duration
+ // -------------------------------------------------------------------------
+
+ /**
+ * Read expire duration in milliseconds.
+ * Reads from key "clipboard_history_expire_preset" (hours) then converts to millis.
+ * If preset = "0" (custom), read from key "clipboard_history_expire_custom_hours".
+ * If preset = "-1" (reboot), return EXPIRE_ON_REBOOT.
+ */
+ public long getExpireMillis() {
+ // Try reading as String (normal path via ListPreference)
+ // If it fails (value stored as int from an old version), fall back to default
+ String presetStr;
+ try {
+ presetStr = prefs.getString(KEY_EXPIRE_PRESET, DEFAULT_EXPIRE_HOURS);
+ } catch (ClassCastException e) {
+ // Value stored as another type (int) — use default
+ presetStr = DEFAULT_EXPIRE_HOURS;
+ }
+ if (presetStr == null || presetStr.isEmpty()) presetStr = DEFAULT_EXPIRE_HOURS;
+
+ try {
+ long hours = Long.parseLong(presetStr);
+ if (hours == -1L) {
+ return ClipboardExpireManager.EXPIRE_ON_REBOOT;
+ }
+ if (hours == 0L) {
+ // Custom: read from EditTextPreference
+ String customStr;
+ try {
+ customStr = prefs.getString(KEY_EXPIRE_CUSTOM, "24");
+ } catch (ClassCastException e) {
+ customStr = "24";
+ }
+ if (customStr == null || customStr.isEmpty()) customStr = "24";
+ long customHours = Long.parseLong(customStr);
+ return ClipboardExpireManager.validateCustomDuration(customHours * 3_600_000L);
+ }
+ return hours * 3_600_000L;
+ } catch (NumberFormatException e) {
+ return ClipboardExpireManager.EXPIRE_1_DAY;
+ }
+ }
+
+ /**
+ * Currently active expire label, to display in the UI.
+ */
+ public String getExpireLabel() {
+ return ClipboardExpireManager.getLabel(getExpireMillis());
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardStorage.java b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardStorage.java
new file mode 100644
index 00000000..1732395e
--- /dev/null
+++ b/app/src/main/java/com/gazlaws/codeboard/clipboard/ClipboardStorage.java
@@ -0,0 +1,438 @@
+package com.gazlaws.codeboard.clipboard;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Handles all read/write operations for clipboard history files.
+ *
+ * Directory structure:
+ * /storage/emulated/0/Android/media//.localcache/copy/
+ * └── /
+ * └── / ← month name in English, e.g. "February"
+ * └── / ← 2-digit date, e.g. "25"
+ * └── clipboard_log.txt
+ *
+ * One file per day. Each entry is appended to the bottom of the file.
+ */
+public class ClipboardStorage {
+
+ private static final String TAG = "ClipboardStorage";
+ private static final String DIR_BASE = ".localcache/copy";
+ private static final String FILE_NAME = "clipboard_log.txt";
+
+ private static final String FMT_YEAR = "yyyy";
+ private static final String FMT_MONTH = "MMMM"; // January, February, ...
+ private static final String FMT_DATE = "dd";
+
+ private final File rootDir;
+
+ public ClipboardStorage(Context context) {
+ // /storage/emulated/0/Android/media//
+ File mediaDir = new File(
+ "/storage/emulated/0/Android/media/" + context.getPackageName()
+ );
+ this.rootDir = new File(mediaDir, DIR_BASE);
+ }
+
+ // -------------------------------------------------------------------------
+ // Public API
+ // -------------------------------------------------------------------------
+
+ /**
+ * Save one entry to today's file.
+ * The directory is created automatically if it doesn't exist.
+ *
+ * @return true on success
+ */
+ public boolean append(ClipboardEntry entry) {
+ File file = getTodayFile(entry.timestamp);
+ if (!ensureDir(file.getParentFile())) {
+ Log.e(TAG, "Failed to create directory: " + file.getParentFile());
+ return false;
+ }
+ try (BufferedWriter bw = new BufferedWriter(new FileWriter(file, true))) {
+ bw.write(entry.serialize());
+ bw.newLine();
+ Log.d(TAG, "Entry saved: " + file.getPath());
+ return true;
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to write entry: " + e.getMessage(), e);
+ return false;
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to serialize entry: " + e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Find an entry with the same text in today's file.
+ * If found, update its TIMESTAMP line with the current time.
+ * If not found, return false (caller must append).
+ *
+ * @param text text to search for
+ * @param newTimestamp new time to write
+ * @return true if the entry was found and updated successfully
+ */
+ public boolean updateTimestampIfExists(String text, long newTimestamp) {
+ File file = getTodayFile(newTimestamp);
+ if (!file.exists()) return false;
+
+ try {
+ String content = readRaw(file);
+ if (content == null) return false;
+
+ String encoded;
+ try {
+ encoded = android.util.Base64.encodeToString(
+ text.getBytes("UTF-8"), android.util.Base64.NO_WRAP);
+ } catch (java.io.UnsupportedEncodingException e) {
+ return false;
+ }
+
+ // Look for an exact line match (not substring) to avoid false positives
+ if (!containsExactLine(content, encoded)) return false;
+
+ String newTs = "TIMESTAMP: " + ClipboardEntry.formatTimestamp(newTimestamp);
+ String updatedContent = replaceTimestampForEncoded(content, encoded, newTs);
+ if (updatedContent == null) return false;
+
+ writeRaw(file, updatedContent);
+ Log.d(TAG, "Timestamp updated for existing text.");
+ return true;
+
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to update timestamp: " + e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Check whether the encoded text exists as a full line within content.
+ * Avoids false positives from substring matches.
+ */
+ private boolean containsExactLine(String content, String encoded) {
+ for (String line : content.split("\n")) {
+ if (line.trim().equals(encoded)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Replace the TIMESTAMP line in the block that contains the encoded text.
+ * Only the first matching block is changed.
+ */
+ private String replaceTimestampForEncoded(String content, String encoded, String newTsLine) {
+ // Find the line that is an exact match with encoded (not substring)
+ // then replace the TIMESTAMP line right before it
+ String[] lines = content.split("\n");
+ StringBuilder sb = new StringBuilder();
+ boolean replaced = false;
+
+ for (int i = 0; i < lines.length; i++) {
+ if (!replaced && lines[i].trim().equals(encoded) && i > 0
+ && lines[i - 1].trim().startsWith("TIMESTAMP:")) {
+ // Replace the TIMESTAMP line that was previously appended
+ // Remove the last suffix (old TIMESTAMP line + \n) then add the new one
+ String built = sb.toString();
+ int lastNl = built.lastIndexOf("\n", built.length() - 2);
+ sb = new StringBuilder(built.substring(0, lastNl + 1));
+ sb.append(newTsLine).append("\n");
+ sb.append(lines[i]).append("\n");
+ replaced = true;
+ } else {
+ sb.append(lines[i]).append("\n");
+ }
+ }
+ return replaced ? sb.toString() : null;
+ }
+
+ /**
+ * Search for the same text in all old files (excluding today's file).
+ * If found, remove that entry from the old file.
+ * Delete empty files and directories after removal.
+ *
+ * @param text text to search for
+ * @param todayFile today's file (excluded from the search)
+ * @return true if found and successfully removed from an old file
+ */
+ public boolean findAndRemoveFromOldFiles(String text, File todayFile) {
+ String encoded;
+ try {
+ encoded = android.util.Base64.encodeToString(
+ text.getBytes("UTF-8"), android.util.Base64.NO_WRAP);
+ } catch (java.io.UnsupportedEncodingException e) {
+ return false;
+ }
+ return searchAndRemove(rootDir, encoded, todayFile);
+ }
+
+ /**
+ * Recursively search for the encoded text across all clipboard_log.txt
+ * files, except todayFile. If found, remove its entry block.
+ */
+ private boolean searchAndRemove(File dir, String encoded, File todayFile) {
+ File[] children = dir.listFiles();
+ if (children == null) return false;
+
+ for (File child : children) {
+ if (child.isDirectory()) {
+ boolean found = searchAndRemove(child, encoded, todayFile);
+ if (found) {
+ // Clean up empty directories after removal
+ deleteEmptyDirsUpTo(child, rootDir);
+ return true;
+ }
+ } else if (child.getName().equals(FILE_NAME)
+ && !child.getAbsolutePath().equals(todayFile.getAbsolutePath())) {
+ try {
+ String content = readRaw(child);
+ if (content != null && containsExactLine(content, encoded)) {
+ String removed = removeEntryByEncoded(content, encoded);
+ if (removed != null) {
+ if (removed.trim().isEmpty()) {
+ // File became empty, just delete it
+ child.delete();
+ Log.d(TAG, "Old file empty after removal, deleted: " + child.getPath());
+ } else {
+ writeRaw(child, removed);
+ Log.d(TAG, "Entry removed from old file: " + child.getPath());
+ }
+ return true;
+ }
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to read/write old file: " + e.getMessage(), e);
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Remove one entry block from content based on the encoded text.
+ * Block format:
+ * === {id} ===\n
+ * TIMESTAMP: ...\n
+ * {encoded}\n
+ * === END {id} ===\n
+ *
+ * @return new content without that block, or null if not found
+ */
+ private String removeEntryByEncoded(String content, String encoded) {
+ // Find the position of the encoded text
+ int encodedPos = content.indexOf(encoded);
+ if (encodedPos < 0) return null;
+
+ // Find the start of the block: walk backward from encodedPos to find "=== " at line start
+ // Structure: header\nTIMESTAMP\nencoded\nfooter
+ // So we need to step back 2 lines from encodedPos
+
+ // Find the start of the encoded line
+ int encodedLineStart = content.lastIndexOf("\n", encodedPos - 1);
+ if (encodedLineStart < 0) encodedLineStart = 0; else encodedLineStart += 1;
+
+ // TIMESTAMP line: one line before encoded
+ int tsLineStart = content.lastIndexOf("\n", encodedLineStart - 2);
+ if (tsLineStart < 0) tsLineStart = 0; else tsLineStart += 1;
+
+ // Header line: one line before TIMESTAMP
+ int headerLineStart = content.lastIndexOf("\n", tsLineStart - 2);
+ if (headerLineStart < 0) headerLineStart = 0; else headerLineStart += 1;
+
+ // Find the end of the block: the "=== END {id} ===" line after encoded
+ int encodedLineEnd = content.indexOf("\n", encodedPos);
+ if (encodedLineEnd < 0) return null;
+ int footerLineEnd = content.indexOf("\n", encodedLineEnd + 1);
+ if (footerLineEnd < 0) footerLineEnd = content.length();
+ else footerLineEnd += 1; // include \n
+
+ // Validate: header must start with "=== "
+ String headerLine = content.substring(headerLineStart,
+ content.indexOf("\n", headerLineStart));
+ if (!headerLine.trim().startsWith("===") || !headerLine.trim().endsWith("===")) {
+ return null;
+ }
+
+ // Remove the block from headerLineStart to footerLineEnd
+ return content.substring(0, headerLineStart)
+ + content.substring(footerLineEnd);
+ }
+
+ /**
+ * Delete empty directories upward from dir, stopping at stopAt.
+ */
+ private void deleteEmptyDirsUpTo(File dir, File stopAt) {
+ File current = dir;
+ while (current != null && !current.getAbsolutePath().equals(stopAt.getAbsolutePath())) {
+ File[] files = current.listFiles();
+ if (files != null && files.length == 0) {
+ current.delete();
+ Log.d(TAG, "Empty directory deleted: " + current.getPath());
+ current = current.getParentFile();
+ } else {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Read the entire file content as a String.
+ * Uses DataInputStream.readFully() to guarantee all bytes are read,
+ * unlike InputStream.read() which can do a partial read on large files.
+ */
+ private String readRaw(File file) throws IOException {
+ if (!file.exists()) return null;
+ byte[] bytes = new byte[(int) file.length()];
+ java.io.DataInputStream dis = new java.io.DataInputStream(
+ new java.io.FileInputStream(file));
+ try {
+ dis.readFully(bytes);
+ } finally {
+ dis.close();
+ }
+ return new String(bytes, "UTF-8");
+ }
+
+ /**
+ * Read the most recent clipboard entries from all files, sorted newest first.
+ * Used to display HIST mode on the keyboard.
+ *
+ * @param maxEntries maximum number of entries to return
+ * @return list of entries, newest at index 0
+ */
+ public java.util.List readRecentEntries(int maxEntries) {
+ java.util.List result = new java.util.ArrayList<>();
+ collectEntries(rootDir, result);
+ java.util.Collections.sort(result, new java.util.Comparator() {
+ @Override
+ public int compare(ClipboardEntry a, ClipboardEntry b) {
+ return Long.compare(b.timestamp, a.timestamp);
+ }
+ });
+ if (result.size() > maxEntries) {
+ return result.subList(0, maxEntries);
+ }
+ return result;
+ }
+
+ private void collectEntries(File dir, java.util.List out) {
+ File[] children = dir.listFiles();
+ if (children == null) return;
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectEntries(child, out);
+ } else if (child.getName().equals(FILE_NAME)) {
+ parseEntriesFromFile(child, out);
+ }
+ }
+ }
+
+ private void parseEntriesFromFile(File file, java.util.List out) {
+ try {
+ String content = readRaw(file);
+ if (content == null || content.trim().isEmpty()) return;
+
+ java.util.List starts = new java.util.ArrayList<>();
+ String[] lines = content.split("\n");
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i].trim();
+ if (line.startsWith("=== ") && line.endsWith("===") && !line.startsWith("=== END")) {
+ starts.add(i);
+ }
+ }
+
+ for (int s = 0; s < starts.size(); s++) {
+ int from = starts.get(s);
+ int to = (s + 1 < starts.size()) ? starts.get(s + 1) : lines.length;
+
+ StringBuilder sb = new StringBuilder();
+ for (int i = from; i < to; i++) {
+ sb.append(lines[i]).append("\n");
+ }
+ ClipboardEntry entry = ClipboardEntry.deserialize(sb.toString().trim());
+ if (entry != null) out.add(entry);
+ }
+
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to parse entries from: " + file.getPath(), e);
+ }
+ }
+
+ /**
+ * Overwrite the entire content of the file.
+ */
+ private void writeRaw(File file, String content) throws IOException {
+ java.io.FileOutputStream fos = new java.io.FileOutputStream(file, false);
+ try {
+ fos.write(content.getBytes("UTF-8"));
+ } finally {
+ fos.close();
+ }
+ }
+
+ /**
+ * Return the File for today's log (based on the timestamp).
+ */
+ public File getTodayFile(long epochMillis) {
+ Date date = new Date(epochMillis);
+ String year = fmt(FMT_YEAR, date);
+ String month = fmt(FMT_MONTH, date);
+ String day = fmt(FMT_DATE, date);
+
+ return new File(rootDir, year + "/" + month + "/" + day + "/" + FILE_NAME);
+ }
+
+ /**
+ * Return the root directory .localcache/copy
+ */
+ public File getRootDir() {
+ return rootDir;
+ }
+
+ /**
+ * Check whether the WRITE_EXTERNAL_STORAGE permission is available
+ * (for Android < 10) or the media directory is writable (Android 10+).
+ *
+ * On Android 10+, accessing Android/media/ requires no special permission.
+ */
+ public boolean isStorageAvailable() {
+ try {
+ File parent = rootDir.getParentFile();
+ if (parent == null) return false;
+ // Try creating the test directory
+ if (!parent.exists()) {
+ parent.mkdirs();
+ }
+ return parent.canWrite();
+ } catch (Exception e) {
+ Log.e(TAG, "Storage is not available: " + e.getMessage());
+ return false;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private boolean ensureDir(File dir) {
+ if (dir == null) return false;
+ if (dir.exists()) return true;
+ return dir.mkdirs();
+ }
+
+ private String fmt(String pattern, Date date) {
+ SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.ENGLISH);
+ sdf.setTimeZone(TimeZone.getDefault());
+ return sdf.format(date);
+ }
+}
diff --git a/app/src/main/java/com/gazlaws/codeboard/layout/Definitions.java b/app/src/main/java/com/gazlaws/codeboard/layout/Definitions.java
index d1b11b93..3c025da8 100644
--- a/app/src/main/java/com/gazlaws/codeboard/layout/Definitions.java
+++ b/app/src/main/java/com/gazlaws/codeboard/layout/Definitions.java
@@ -7,13 +7,29 @@
public class Definitions {
private Context context;
- private static final int CODE_ESCAPE = -2;
+ private static final int CODE_ESCAPE = -2;
private static final int CODE_SYMBOLS = -1;
+ // Special code for the HIST key — same value as CODE_SYMBOLS (-1) because
+ // onKey case -1 handles mode toggling based on the ctrl+shift state.
+ // Kept as a separate constant for clarity and easier future changes.
+ private static final int CODE_HISTORY = -1;
public Definitions(Context current) {
this.context = current;
}
+ public void addHistoryTopRow(KeyboardLayoutBuilder keyboard) {
+ keyboard.newRow()
+ .addKey("Esc", CODE_ESCAPE)
+ .addTabKey()
+ .addKey(context.getDrawable(R.drawable.ic_keyboard_arrow_left_24dp), 5000).asRepeatable()
+ .addKey(context.getDrawable(R.drawable.ic_keyboard_arrow_down_24dp), 5001).asRepeatable()
+ .addKey(context.getDrawable(R.drawable.ic_keyboard_arrow_up_24dp), 5002).asRepeatable()
+ .addKey(context.getDrawable(R.drawable.ic_keyboard_arrow_right_24dp), 5003).asRepeatable()
+ .addKey("HIST", CODE_HISTORY)
+ ;
+ }
+
public void addArrowsRow(KeyboardLayoutBuilder keyboard) {
int CODE_ARROW_LEFT = 5000;
int CODE_ARROW_DOWN = 5001;
diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml
index aa75ad3b..acd6e19c 100644
--- a/app/src/main/res/values/array.xml
+++ b/app/src/main/res/values/array.xml
@@ -30,4 +30,27 @@
- 5
- 6
-
\ No newline at end of file
+
+
+
+
+ - 1 hour
+ - 8 hours
+ - 1 day
+ - 2 days
+ - 1 week
+ - 30 days
+ - On reboot
+ - Custom...
+
+
+ - 1
+ - 8
+ - 24
+ - 48
+ - 168
+ - 720
+ - -1
+ - 0
+
+
diff --git a/app/src/main/res/values/integer.xml b/app/src/main/res/values/integer.xml
index 444d4ecd..90c5163e 100644
--- a/app/src/main/res/values/integer.xml
+++ b/app/src/main/res/values/integer.xml
@@ -3,4 +3,5 @@
0
1
2
-
\ No newline at end of file
+ 3
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d24f641b..3354f0d1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -20,4 +20,25 @@
Fix popup
Settings
This app is free, open-source and does not read your data! You can view, modify and compile the code yourself on GitHub.
+
+
+ Backup & Restore
+ Export settings
+ Save all settings, symbols, and pins to a .json file
+ Import settings
+ Restore settings from a .json backup file
+ Backup saved successfully
+ Backup restored successfully. Restart the app to apply changes.
+ Failed:
+
+
+ Clipboard History
+ Save clipboard history
+ Every copied text will be saved to local storage
+ Auto-delete after
+ 1 day
+ Custom duration (hours)
+ Minimum 1 hour, maximum 2160 hours (90 days)
+ Storage location
+ Android/media/com.gazlaws.codeboard/.localcache/copy/
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 7c744130..cf5cec12 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -188,6 +188,47 @@
android:title="Pin 7:"
app:useSimpleSummaryProvider="true" />
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/local.properties.example b/local.properties.example
new file mode 100644
index 00000000..9d79df3d
--- /dev/null
+++ b/local.properties.example
@@ -0,0 +1,12 @@
+# local.properties.example
+# Copy this file to local.properties and fill in your keystore path
+# DO NOT commit local.properties to git!
+
+# SDK path (required)
+sdk.dir=/path/to/Android/Sdk
+
+# Signing config for Android Studio
+signing.keystore.path=/path/to/release.keystore
+signing.store.password=
+signing.key.alias=
+signing.key.password=
diff --git a/scripts/build_local.sh b/scripts/build_local.sh
new file mode 100644
index 00000000..a2bb6044
--- /dev/null
+++ b/scripts/build_local.sh
@@ -0,0 +1,147 @@
+#!/bin/bash
+# build_local.sh
+# Local codeboard build — same as GitHub Actions
+#
+# Usage:
+# bash scripts/build_local.sh [--full]
+#
+# Example:
+# bash scripts/build_local.sh V6.0.3 → build only
+# bash scripts/build_local.sh V6.0.3 --full → build + push release
+#
+# Required environment variables:
+# SIGNING_KEYSTORE_BASE64 → keystore in base64 (optional)
+# SIGNING_STORE_PASSWORD → keystore password
+# SIGNING_KEY_ALIAS → key alias
+# SIGNING_KEY_PASSWORD → key password
+# GITHUB_TOKEN → GitHub token (for --full)
+#
+# Or store in local.properties:
+# signing.keystore.path=/path/to/release.keystore
+# signing.store.password=
+# signing.key.alias=
+# signing.key.password=
+
+set -e
+
+# ── Colors ──────────────────────────────────────────────────────────────────
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
+step() { echo -e "\n${BLUE}▶ $1${NC}"; }
+ok() { echo -e "${GREEN}✓ $1${NC}"; }
+warn() { echo -e "${YELLOW}⚠ $1${NC}"; }
+error() { echo -e "${RED}✗ $1${NC}"; exit 1; }
+
+# ── Arguments ─────────────────────────────────────────────────────────────────
+TAG="${1:-dev}"
+FULL_MODE=false
+[[ "$2" == "--full" ]] && FULL_MODE=true
+
+echo -e "${BLUE}======================================"
+echo " Codeboard Local Build"
+echo " TAG : $TAG"
+echo " Mode : $([ "$FULL_MODE" == true ] && echo 'FULL' || echo 'BUILD ONLY')"
+echo -e "======================================${NC}"
+
+# ── Check dependencies ──────────────────────────────────────────────────────
+step "Checking dependencies"
+command -v java >/dev/null 2>&1 || error "Java not found. Install JDK 17."
+command -v git >/dev/null 2>&1 || error "Git not found."
+[ -f "gradlew" ] || error "Run this from the project root."
+chmod +x gradlew
+ok "All dependencies available"
+
+# ── Extract version ───────────────────────────────────────────────────────────
+step "Extracting version"
+VERSION_CODE=$(grep 'versionCode' app/build.gradle | tr -dc '0-9')
+VERSION_NAME=$(grep 'versionName' app/build.gradle | sed 's/.*"\(.*\)".*/\1/')
+ok "Version code : $VERSION_CODE"
+ok "Version name : $VERSION_NAME"
+
+# ── Keystore ──────────────────────────────────────────────────────────────────
+step "Checking keystore"
+HAS_KEYSTORE=false
+mkdir -p app/keystore
+
+if [ -n "$SIGNING_KEYSTORE_BASE64" ]; then
+ # From env variable (base64)
+ echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > app/keystore/release.keystore
+ export SIGNING_KEYSTORE_PATH="$(pwd)/app/keystore/release.keystore"
+ HAS_KEYSTORE=true
+ ok "Keystore from SIGNING_KEYSTORE_BASE64"
+
+elif [ -n "$SIGNING_KEYSTORE_PATH" ] && [ -f "$SIGNING_KEYSTORE_PATH" ]; then
+ # From direct env variable path
+ HAS_KEYSTORE=true
+ ok "Keystore from SIGNING_KEYSTORE_PATH: $SIGNING_KEYSTORE_PATH"
+
+elif [ -f "local.properties" ]; then
+ # From local.properties (Android Studio style)
+ LOCAL_PATH=$(grep 'signing.keystore.path' local.properties | cut -d'=' -f2 | tr -d ' ')
+ if [ -n "$LOCAL_PATH" ] && [ -f "$LOCAL_PATH" ]; then
+ HAS_KEYSTORE=true
+ ok "Keystore from local.properties: $LOCAL_PATH"
+ fi
+fi
+
+if [ "$HAS_KEYSTORE" != true ]; then
+ warn "Keystore not found → using debug keystore"
+fi
+
+# ── Build Debug ───────────────────────────────────────────────────────────────
+step "Build Debug APK"
+./gradlew assembleDebug --no-daemon
+ok "Debug APK done"
+
+# ── Build Release ─────────────────────────────────────────────────────────────
+step "Build Release APK"
+./gradlew assembleRelease --no-daemon
+if [ "$HAS_KEYSTORE" == true ]; then
+ ok "Release APK done (signed with release keystore)"
+else
+ ok "Release APK done (signed with debug keystore)"
+fi
+
+# ── Rename APK ────────────────────────────────────────────────────────────────
+step "Rename APK"
+mkdir -p artifacts
+cp app/build/outputs/apk/debug/app-debug.apk artifacts/codeboard-${TAG}-debug.apk
+cp app/build/outputs/apk/release/app-release.apk artifacts/codeboard-${TAG}-release.apk
+cp artifacts/codeboard-${TAG}-release.apk artifacts/codeboard-release.apk
+ok "artifacts/codeboard-${TAG}-debug.apk"
+ok "artifacts/codeboard-${TAG}-release.apk"
+ok "artifacts/codeboard-release.apk"
+
+# ── Done if not full mode ──────────────────────────────────────────────────────
+if [ "$FULL_MODE" != true ]; then
+ echo -e "\n${GREEN}======================================"
+ echo " Build done!"
+ echo " APK is in the artifacts/ folder"
+ echo -e "======================================${NC}"
+ exit 0
+fi
+
+# ── [FULL] Push Release to GitHub ───────────────────────────────────────────
+[ -n "$GITHUB_TOKEN" ] || error "GITHUB_TOKEN is not set."
+command -v gh >/dev/null 2>&1 || error "GitHub CLI (gh) not found."
+
+if [[ "$TAG" == V* ]]; then
+ step "Push Release to GitHub"
+ CHANGELOG=$(git log -1 --pretty=%s)
+ gh release create "$TAG" \
+ artifacts/codeboard-${TAG}-release.apk \
+ --title "Codeboard $TAG" \
+ --notes "$CHANGELOG" \
+ --latest
+ ok "Release pushed successfully"
+else
+ warn "TAG is not V* → skipping release push"
+fi
+
+# ── Clean up temporary keystore ───────────────────────────────────────────────
+[ -f app/keystore/release.keystore ] && rm app/keystore/release.keystore
+
+echo -e "\n${GREEN}======================================"
+echo " All done!"
+echo " Tag : $TAG"
+echo " APK : artifacts/codeboard-${TAG}-release.apk"
+echo -e "======================================${NC}"