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}"