diff --git a/.gitignore b/.gitignore index b059757..0b37b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,6 @@ dist/ web-build/ # Native -android/ -ios/ *.orig.* *.jks *.p8 diff --git a/NATIVE_CHANGES.md b/NATIVE_CHANGES.md new file mode 100644 index 0000000..b31cd21 --- /dev/null +++ b/NATIVE_CHANGES.md @@ -0,0 +1,109 @@ +# Native Changes + +This file documents all manual modifications to the `android/` folder that are **not** generated by `expo prebuild`. +Update this file whenever you add or modify native files. + +> ⚠️ Do **not** run `expo prebuild --clean` — use `expo prebuild` (no `--clean`) to preserve these changes. +> Use `npm run prebuild:reset` only when intentionally wiping native code, then re-apply all changes listed here. + +--- + +## 1. Android Home Screen Widget + +**Added:** May 2026 +**Purpose:** Displays a checklist from the app's SQLite database as an interactive Android home screen widget. Users can tap checkboxes directly in the widget to toggle item completion, which writes back to the same `content` table the app uses. + +### New files added + +#### Kotlin source files + +Location: `android/app/src/main/java/com/pgarr/simplenotepad/widget/` + +| File | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `NoteListWidget.kt` | `AppWidgetProvider` — entry point, called by Android on widget add/update. Reads the latest list from SQLite and binds it to the `ListView` via `RemoteViewsService`. | +| `NoteListWidgetService.kt` | `RemoteViewsService` — Android requires a bound service to supply list row views to a widget `ListView`. Instantiates `NoteListRemoteViewsFactory`. | +| `NoteListRemoteViewsFactory.kt` | `RemoteViewsFactory` — builds each list row `RemoteViews`. Sets the checkbox icon (on/off), text, opacity for completed items, and attaches a fill-in `Intent` to each row for tap handling. | +| `WidgetDbHelper.kt` | Opens the app's SQLite database directly using Android's `SQLiteDatabase` API (not expo-sqlite). Provides `getLatestList()` and `toggleItem()`. Parses and writes the `note` column JSON in a format exactly matching the JS `parseListItems` / `stringifyListItems` functions in `db.ts`. | +| `WidgetUpdateReceiver.kt` | `BroadcastReceiver` — receives checkbox tap broadcasts, calls `WidgetDbHelper.toggleItem()`, then calls `notifyAppWidgetViewDataChanged` to re-render the widget list. | + +#### Resource files + +Location: `android/app/src/main/res/` + +| File | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `layout/widget_note_list.xml` | Root widget layout. Contains a `TextView` for the list title and a `ListView` for the items. | +| `layout/widget_list_item.xml` | Single row layout. Contains an `ImageView` acting as a checkbox (swapped between `checkbox_on_background` / `checkbox_off_background` drawables) and a `TextView` for item text. Note: real `CheckBox` views cannot be used interactively in `RemoteViews`. | +| `xml/note_list_widget_info.xml` | `AppWidgetProviderInfo` — declares widget minimum size (250×180dp), resize behaviour, update interval, and initial layout. | + +### AndroidManifest.xml modifications + +File: `android/app/src/main/AndroidManifest.xml` + +Three entries added inside ``: + +```xml + + + + + + + + + + + + + + + + + +``` + +--- + +## Schema dependency + +The widget reads from the same database and table as the main app. + +| Property | Value | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Database file | `notes.db` — **must match** the string passed to `openDatabaseAsync()` in JS. Defined in `WidgetDbHelper.kt` as `DB_NAME`. | +| Table | `content` | +| Relevant columns | `id`, `title`, `note` (JSON string), `type` (0 = note, 1 = list) | +| Widget reads | `SELECT * FROM content WHERE type = 1 ORDER BY id DESC LIMIT 1` | +| Widget writes | `UPDATE content SET note = ? WHERE id = ? AND type = 1` | + +The JSON format of the `note` column for lists is an array of `{ "checked": boolean, "text": string }` objects, matching `parseListItems` / `stringifyListItems` in `db.ts`. + +--- + +## Known caveats + +- **Stale app state:** If the user taps a checkbox in the widget while the app is open on the same list, the app's in-memory state will be stale until it re-fetches. Add a re-fetch in your list screen's `useEffect` on `AppState` change event to handle this. +- **Concurrent writes:** The widget and the app both write to the same SQLite file. SQLite WAL mode (enabled in migrations) handles concurrent reads safely, but avoid triggering widget updates and app writes simultaneously. In practice this is unlikely for a notepad app. +- **Which list is shown:** Currently the widget always shows the list with the highest `id`. There is no per-widget configuration (pinning a specific list). This could be added via `AppWidgetConfigureActivity` in a future iteration. +- **Update interval:** Set to 30 minutes (`updatePeriodMillis="1800000"`) in `note_list_widget_info.xml`. Android batches and throttles this — do not rely on it for real-time updates. The widget updates immediately on checkbox tap via the broadcast receiver. + +--- + +## Upgrade notes (Expo SDK upgrades) + +When upgrading the Expo SDK: + +1. Run `expo prebuild` (no `--clean`) to regenerate native files with merge. +2. Resolve any conflicts in `AndroidManifest.xml` — the three widget entries above must be preserved. +3. The Kotlin files and resource files in the `widget/` package and `res/` subfolders are entirely new additions, so they will not have conflicts. +4. Verify `WidgetDbHelper.kt`'s `DB_NAME` constant still matches the database name used in JS after the upgrade. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..e12f7f3 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,182 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.pgarr.simplenotepad' + defaultConfig { + applicationId 'com.pgarr.simplenotepad' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 13 + versionName "1.1.1" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..905a0af --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/MainActivity.kt b/android/app/src/main/java/com/pgarr/simplenotepad/MainActivity.kt new file mode 100644 index 0000000..5583af9 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/MainActivity.kt @@ -0,0 +1,65 @@ +package com.pgarr.simplenotepad +import expo.modules.splashscreen.SplashScreenManager + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/MainApplication.kt b/android/app/src/main/java/com/pgarr/simplenotepad/MainApplication.kt new file mode 100644 index 0000000..ce4d0a0 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/MainApplication.kt @@ -0,0 +1,56 @@ +package com.pgarr.simplenotepad + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +import com.pgarr.simplenotepad.widget.WidgetRefreshPackage +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + add(WidgetRefreshPackage()) + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListRemoteViewsFactory.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListRemoteViewsFactory.kt new file mode 100644 index 0000000..679e26e --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListRemoteViewsFactory.kt @@ -0,0 +1,70 @@ +package com.pgarr.simplenotepad.widget + +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import com.pgarr.simplenotepad.R + +class NoteListRemoteViewsFactory( + private val context: Context, + @Suppress("UNUSED_PARAMETER") private val listId: Int +) : RemoteViewsService.RemoteViewsFactory { + + private var items: List = emptyList() + private var listTitle: String = "" + /** Id of the list rows are from — always aligned with [onDataSetChanged] (latest list). */ + private var boundListId: Int = -1 + + override fun onCreate() {} + + override fun onDataSetChanged() { + val list = WidgetDbHelper.getLatestList(context) + if (list == null) { + items = emptyList() + listTitle = "" + boundListId = -1 + return + } + items = list.items + listTitle = list.title + boundListId = list.id + } + + override fun onDestroy() {} + + override fun getCount(): Int = items.size + + override fun getViewAt(position: Int): RemoteViews { + val item = items[position] + val rv = RemoteViews(context.packageName, R.layout.widget_list_item) + + // Set text, with strikethrough if checked + rv.setTextViewText(R.id.item_text, item.text) + + // Swap checkbox icon based on checked state + val checkboxDrawable = if (item.checked) + android.R.drawable.checkbox_on_background + else + android.R.drawable.checkbox_off_background + rv.setImageViewResource(R.id.item_checkbox, checkboxDrawable) + + // Dim completed items + rv.setFloat(R.id.item_text, "setAlpha", if (item.checked) 0.4f else 1.0f) + + // Fill-in intent carries position and listId to the broadcast receiver + val fillIntent = Intent().apply { + putExtra("item_index", position) + putExtra("list_id", boundListId) + } + rv.setOnClickFillInIntent(R.id.item_checkbox, fillIntent) + rv.setOnClickFillInIntent(R.id.item_text, fillIntent) + + return rv + } + + override fun getLoadingView(): RemoteViews? = null + override fun getViewTypeCount(): Int = 1 + override fun getItemId(position: Int): Long = position.toLong() + override fun hasStableIds(): Boolean = false +} \ No newline at end of file diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidget.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidget.kt new file mode 100644 index 0000000..c781f71 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidget.kt @@ -0,0 +1,99 @@ +package com.pgarr.simplenotepad.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.RemoteViews +import com.pgarr.simplenotepad.MainActivity +import com.pgarr.simplenotepad.R + +class NoteListWidget : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (widgetId in appWidgetIds) { + updateWidget(context, appWidgetManager, widgetId) + } + } + + companion object { + fun refreshAllWidgets(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val component = ComponentName(context, NoteListWidget::class.java) + val ids = appWidgetManager.getAppWidgetIds(component) + for (widgetId in ids) { + updateWidget(context, appWidgetManager, widgetId) + } + if (ids.isNotEmpty()) { + appWidgetManager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list_view) + } + } + + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + widgetId: Int + ) { + val latestList = WidgetDbHelper.getLatestList(context) + + val rv = RemoteViews(context.packageName, R.layout.widget_note_list) + + rv.setTextViewText(R.id.widget_title, latestList?.title ?: "No lists") + + val listDeepLink = + if (latestList != null) { + Uri.parse("simple-notepad:///list/${latestList.id}") + } else { + Uri.parse("simple-notepad:///") + } + val openListIntent = + Intent(Intent.ACTION_VIEW, listDeepLink).apply { + setClass(context, MainActivity::class.java) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val openListFlags = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + val openListPendingIntent = + PendingIntent.getActivity( + context, + widgetId + 10_000, + openListIntent, + openListFlags + ) + rv.setOnClickPendingIntent(R.id.widget_title, openListPendingIntent) + + val serviceIntent = Intent(context, NoteListWidgetService::class.java).apply { + putExtra("list_id", latestList?.id ?: -1) + data = android.net.Uri.parse("widget://list/$widgetId") + } + rv.setRemoteAdapter(R.id.widget_list_view, serviceIntent) + rv.setEmptyView(R.id.widget_list_view, R.id.widget_list_empty) + + val toggleIntent = Intent(context, WidgetUpdateReceiver::class.java).apply { + action = WidgetUpdateReceiver.ACTION_TOGGLE_ITEM + } + val pendingIntent = PendingIntent.getBroadcast( + context, + widgetId, + toggleIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + rv.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent) + + appWidgetManager.updateAppWidget(widgetId, rv) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidgetService.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidgetService.kt new file mode 100644 index 0000000..2e08ad9 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/NoteListWidgetService.kt @@ -0,0 +1,11 @@ +package com.pgarr.simplenotepad.widget + +import android.content.Intent +import android.widget.RemoteViewsService + +class NoteListWidgetService : RemoteViewsService() { + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + val listId = intent.getIntExtra("list_id", -1) + return NoteListRemoteViewsFactory(applicationContext, listId) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetDbHelper.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetDbHelper.kt new file mode 100644 index 0000000..e416628 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetDbHelper.kt @@ -0,0 +1,117 @@ +package com.pgarr.simplenotepad.widget + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import org.json.JSONArray +import java.io.File + +// Mirrors your expo-sqlite DB schema exactly +data class WidgetListItem(val text: String, val checked: Boolean) +data class WidgetList(val id: Int, val title: String, val items: List) + +object WidgetDbHelper { + + // Must match the name you pass to openDatabaseAsync() in your JS code + private const val DB_NAME = "notes.db" + private const val LIST_TYPE = 1 + + /** Same file expo-sqlite uses (`defaultDatabaseDirectory` + name), resolved canonically. */ + private fun getDbPath(context: Context): String { + val file = File(File(context.filesDir, "SQLite"), DB_NAME) + return try { + file.canonicalPath + } catch (_: Exception) { + file.absolutePath + } + } + + private fun openDbWritable(context: Context): SQLiteDatabase { + val path = getDbPath(context) + return SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE) + } + + /** + * Returns the most recently inserted list (highest id), or null if none exist. + * Opens and closes the DB on every call — safe for widget use. + */ + fun getLatestList(context: Context): WidgetList? { + val path = getDbPath(context) + return try { + val db = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY) + db.use { + val cursor = + it.rawQuery( + "SELECT id, title, note FROM content WHERE type = ? ORDER BY id DESC LIMIT 1", + arrayOf(LIST_TYPE.toString()) + ) + cursor.use { c -> + if (!c.moveToFirst()) return@use null + val rowId = c.getInt(c.getColumnIndexOrThrow("id")) + val title = c.getString(c.getColumnIndexOrThrow("title")) + val note = c.getString(c.getColumnIndexOrThrow("note")) + WidgetList(rowId, title, parseItems(note)) + } + } + } catch (_: Exception) { + null + } + } + + /** + * Toggles the checked state of a single item at [itemIndex] within + * the list identified by [listId], then persists the updated JSON. + */ + fun toggleItem(context: Context, listId: Int, itemIndex: Int) { + try { + openDbWritable(context).use { db -> + val currentNote = + db.rawQuery( + "SELECT note FROM content WHERE id = ? AND type = ?", + arrayOf(listId.toString(), LIST_TYPE.toString()) + ).use { c -> + if (!c.moveToFirst()) return + c.getString(c.getColumnIndexOrThrow("note")) + } + + val items = parseItems(currentNote).toMutableList() + if (itemIndex < 0 || itemIndex >= items.size) return + items[itemIndex] = items[itemIndex].copy(checked = !items[itemIndex].checked) + + val newNote = stringifyItems(items) + val stmt = db.compileStatement("UPDATE content SET note = ? WHERE id = ? AND type = ?") + stmt.bindString(1, newNote) + stmt.bindLong(2, listId.toLong()) + stmt.bindLong(3, LIST_TYPE.toLong()) + stmt.executeUpdateDelete() + stmt.close() + } + } catch (_: Exception) {} + } + + // Mirrors parseListItems() from your db.ts + fun parseItems(raw: String): List { + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { i -> + val obj = arr.optJSONObject(i) ?: return@mapNotNull null + val text = obj.optString("text", "") + val checked = obj.optBoolean("checked", false) + WidgetListItem(text = text, checked = checked) + } + } catch (_: Exception) { + emptyList() + } + } + + // Mirrors stringifyListItems() from your db.ts + private fun stringifyItems(items: List): String { + val arr = JSONArray() + items.forEach { item -> + val obj = org.json.JSONObject() + obj.put("checked", item.checked) + obj.put("text", item.text.trim()) + arr.put(obj) + } + return arr.toString() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshModule.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshModule.kt new file mode 100644 index 0000000..6263d38 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshModule.kt @@ -0,0 +1,25 @@ +package com.pgarr.simplenotepad.widget + +import android.os.Handler +import android.os.Looper +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class WidgetRefreshModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "WidgetRefresh" + + @ReactMethod + fun refreshNoteListWidgets() { + val context = reactApplicationContext.applicationContext + val refresh = Runnable { NoteListWidget.refreshAllWidgets(context) } + val activity = reactApplicationContext.currentActivity + if (activity != null) { + activity.runOnUiThread(refresh) + } else { + Handler(Looper.getMainLooper()).post(refresh) + } + } +} diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshPackage.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshPackage.kt new file mode 100644 index 0000000..a7ce05b --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetRefreshPackage.kt @@ -0,0 +1,16 @@ +package com.pgarr.simplenotepad.widget + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class WidgetRefreshPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(WidgetRefreshModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetUpdateReceiver.kt b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetUpdateReceiver.kt new file mode 100644 index 0000000..1f06125 --- /dev/null +++ b/android/app/src/main/java/com/pgarr/simplenotepad/widget/WidgetUpdateReceiver.kt @@ -0,0 +1,32 @@ +package com.pgarr.simplenotepad.widget + +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent + +class WidgetUpdateReceiver : BroadcastReceiver() { + + companion object { + const val ACTION_TOGGLE_ITEM = "com.pgarr.simplenotepad.TOGGLE_ITEM" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_TOGGLE_ITEM) return + + val itemIndex = intent.getIntExtra("item_index", -1) + val listId = intent.getIntExtra("list_id", -1) + if (itemIndex < 0 || listId < 0) return + + // Write the toggle to SQLite + WidgetDbHelper.toggleItem(context, listId, itemIndex) + + // Notify all widget instances to re-render + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds( + ComponentName(context, NoteListWidget::class.java) + ) + manager.notifyAppWidgetViewDataChanged(ids, com.pgarr.simplenotepad.R.id.widget_list_view) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..bad05de Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..83233c9 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..f2385e0 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..b1694b8 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..67c1d54 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..630a8ab --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/widget_list_item.xml b/android/app/src/main/res/layout/widget_list_item.xml new file mode 100644 index 0000000..b4954ac --- /dev/null +++ b/android/app/src/main/res/layout/widget_list_item.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/widget_note_list.xml b/android/app/src/main/res/layout/widget_note_list.xml new file mode 100644 index 0000000..e7ebd58 --- /dev/null +++ b/android/app/src/main/res/layout/widget_note_list.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c264af9 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ba02387 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1322946 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..76a7c01 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..05e1c70 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..afd05af Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..2ad9880 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..584cb72 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4dfce0a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7eee34a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..68affb1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2e4e2c2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..ed1d0a1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..cd6ba83 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e366a7a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..3c05de5 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0602cb7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + #ffffff + #ffffff + #023c69 + #ffffff + + #FFFFFF + #E5E5E5 + #0A0A0A + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1359783 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + simple-notepad + No items in this list + contain + false + automatic + 1.1.1 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..45a97e6 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/note_list_widget_info.xml b/android/app/src/main/res/xml/note_list_widget_info.xml new file mode 100644 index 0000000..1b373b9 --- /dev/null +++ b/android/app/src/main/res/xml/note_list_widget_info.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8e39f82 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,65 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..7f94d3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..fdd7d77 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'simple-notepad' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/app.json b/app.json index 5c657c5..da9742a 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "simple-notepad", "slug": "simple-notepad", - "version": "1.2.1", + "version": "1.3.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "simple-notepad", @@ -25,7 +25,7 @@ "backgroundColor": "#ffffff" }, "package": "com.pgarr.simplenotepad", - "versionCode": 15 + "versionCode": 16 }, "web": { "bundler": "metro", @@ -44,7 +44,7 @@ "projectId": "9e3820b7-558b-4bd2-a1b2-e49561e741e6" } }, - "runtimeVersion": "1.2.1", + "runtimeVersion": "1.3.0", "updates": { "url": "https://u.expo.dev/9e3820b7-558b-4bd2-a1b2-e49561e741e6" } diff --git a/app/_layout.tsx b/app/_layout.tsx index 39e440b..0972806 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,6 @@ import '@/global.css'; -import { migrateDbIfNeeded } from '@/lib/dataStorage'; +import { migrateDbIfNeeded, SQLITE_DATABASE_NAME } from '@/lib/dataStorage'; import { NAV_THEME } from '@/lib/theme'; import { ThemeProvider } from '@react-navigation/native'; import { PortalHost } from '@rn-primitives/portal'; @@ -19,7 +19,7 @@ export default function RootLayout() { return ( - + diff --git a/app/edit-list/[id].tsx b/app/edit-list/[id].tsx index a8314c4..a996293 100644 --- a/app/edit-list/[id].tsx +++ b/app/edit-list/[id].tsx @@ -12,7 +12,9 @@ import { } from '@/lib/dataStorage'; import { useSQLiteContext } from 'expo-sqlite'; import { Stack, useRouter } from 'expo-router'; +import { useFocusEffect } from '@react-navigation/native'; import { useCallback, useEffect, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; import { ListForm } from '@/components/ListForm'; type ListEditState = @@ -49,8 +51,20 @@ export default function EditListScreen() { setList({ title: content.title, items: loadedItems }); }, [db, isValidId, listId]); + useFocusEffect( + useCallback(() => { + loadList(); + }, [loadList]) + ); + useEffect(() => { - loadList(); + const onAppState = (state: AppStateStatus) => { + if (state === 'active') { + loadList(); + } + }; + const sub = AppState.addEventListener('change', onAppState); + return () => sub.remove(); }, [loadList]); const handleBackToPreviousScreen = useCallback(() => { diff --git a/app/index.tsx b/app/index.tsx index c11f081..20e3cfd 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -7,8 +7,8 @@ import { useSQLiteContext } from 'expo-sqlite'; import { Stack, useRouter } from 'expo-router'; import { useFocusEffect } from '@react-navigation/native'; import { PlusIcon } from 'lucide-react-native'; -import { useCallback, useState } from 'react'; -import { BackHandler, Pressable, ScrollView } from 'react-native'; +import { useCallback, useEffect, useState } from 'react'; +import { AppState, type AppStateStatus, BackHandler, Pressable, ScrollView } from 'react-native'; export default function Screen() { const db = useSQLiteContext(); @@ -32,6 +32,16 @@ export default function Screen() { }, [loadNotes]) ); + useEffect(() => { + const onAppState = (state: AppStateStatus) => { + if (state === 'active') { + loadNotes(); + } + }; + const sub = AppState.addEventListener('change', onAppState); + return () => sub.remove(); + }, [loadNotes]); + return ( <> { + loadList(); + }, [loadList]) + ); + useEffect(() => { - loadList(); + const onAppState = (state: AppStateStatus) => { + if (state === 'active') { + loadList(); + } + }; + const sub = AppState.addEventListener('change', onAppState); + return () => sub.remove(); }, [loadList]); useHardwareBackHandler(() => { @@ -71,6 +84,14 @@ export default function ListViewScreen() { [db, listView] ); + const handleToggleAllChecked = useCallback(async () => { + if (listView === 'loading' || listView === null || listView.items.length === 0) return; + const allChecked = listView.items.every((item) => item.checked); + const updatedItems = listView.items.map((item) => ({ ...item, checked: !allChecked })); + setListView({ ...listView, items: updatedItems }); + await updateListItems(db, listView.id, updatedItems); + }, [db, listView]); + const handleDeletePress = useCallback(() => { if (listView === null || listView === 'loading') return; Alert.alert('Delete list', 'Are you sure you want to delete this list?', [ @@ -106,24 +127,38 @@ export default function ListViewScreen() { accessibilityLabel="Back to notes" /> ), - headerRight: () => ( - - - - - ), + headerRight: () => { + const allChecked = + listView.items.length > 0 && listView.items.every((item) => item.checked); + return ( + + + + + + + + ); + }, }} /> diff --git a/lib/__tests__/dataStorage.test.ts b/lib/__tests__/dataStorage.test.ts index 0744b5c..a6fb2d3 100644 --- a/lib/__tests__/dataStorage.test.ts +++ b/lib/__tests__/dataStorage.test.ts @@ -32,7 +32,7 @@ describe('lib/dataStorage', () => { describe('migrateDbIfNeeded', () => { it('early-returns when PRAGMA user_version >= DATABASE_VERSION', async () => { const db = makeDb(); - db.getFirstAsync.mockResolvedValue({ user_version: 2 }); + db.getFirstAsync.mockResolvedValue({ user_version: 3 }); await migrateDbIfNeeded(db as any); @@ -42,6 +42,17 @@ describe('lib/dataStorage', () => { expect(db.runAsync).not.toHaveBeenCalled(); }); + it('migrates v2 to v3 by switching journal mode off WAL', async () => { + const db = makeDb(); + db.getFirstAsync.mockResolvedValue({ user_version: 2 }); + db.execAsync.mockResolvedValue(undefined); + + await migrateDbIfNeeded(db as any); + + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA journal_mode = DELETE'); + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA user_version = 3'); + }); + it('creates base table and adds type column when meta is missing and type is absent', async () => { const db = makeDb(); db.getFirstAsync.mockResolvedValue(null); @@ -51,6 +62,7 @@ describe('lib/dataStorage', () => { await migrateDbIfNeeded(db as any); + expect(db.execAsync).toHaveBeenCalledWith(expect.stringContaining('PRAGMA journal_mode = DELETE')); expect(db.execAsync).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE content')); expect(db.getAllAsync).toHaveBeenCalledWith('PRAGMA table_info(content)'); expect(db.execAsync).toHaveBeenCalledWith( @@ -59,7 +71,8 @@ describe('lib/dataStorage', () => { expect(db.runAsync).toHaveBeenCalledWith( expect.stringContaining('UPDATE content SET type =') ); - expect(db.execAsync).toHaveBeenCalledWith('PRAGMA user_version = 2'); + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA journal_mode = DELETE'); + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA user_version = 3'); }); it('when user_version is old and type column exists, it skips ALTER TABLE but sets default types', async () => { @@ -80,7 +93,8 @@ describe('lib/dataStorage', () => { expect(db.runAsync).toHaveBeenCalledWith( expect.stringContaining('UPDATE content SET type =') ); - expect(db.execAsync).toHaveBeenCalledWith('PRAGMA user_version = 2'); + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA journal_mode = DELETE'); + expect(db.execAsync).toHaveBeenCalledWith('PRAGMA user_version = 3'); }); }); diff --git a/lib/dataStorage.ts b/lib/dataStorage.ts index 4f766a4..d4dec46 100644 --- a/lib/dataStorage.ts +++ b/lib/dataStorage.ts @@ -1,6 +1,9 @@ import { type SQLiteDatabase } from 'expo-sqlite'; +import { NativeModules, Platform } from 'react-native'; -const DATABASE_VERSION = 2; +export const SQLITE_DATABASE_NAME = 'notes.db'; + +const DATABASE_VERSION = 3; export const NOTE_TYPE = 0 as const; export const LIST_TYPE = 1 as const; @@ -44,6 +47,12 @@ const parseListItems = (rawContent: string): ListItem[] => { const stringifyListItems = (items: ListItem[]) => JSON.stringify(items.map((item) => ({ checked: !!item.checked, text: item.text.trim() }))); +/** Home-screen widget reads `content`; refresh right after app writes (listener alone can lag behind minimize). */ +function syncAndroidNoteListWidgetFromApp(): void { + if (Platform.OS !== 'android') return; + NativeModules.WidgetRefresh?.refreshNoteListWidgets?.(); +} + export const migrateDbIfNeeded = async (db: SQLiteDatabase) => { const meta = await db.getFirstAsync<{ user_version: number; @@ -53,7 +62,7 @@ export const migrateDbIfNeeded = async (db: SQLiteDatabase) => { } if (!meta || meta.user_version === 0) { await db.execAsync(` - PRAGMA journal_mode = 'wal'; + PRAGMA journal_mode = DELETE; CREATE TABLE content (id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL); `); } @@ -67,6 +76,11 @@ export const migrateDbIfNeeded = async (db: SQLiteDatabase) => { } await db.runAsync(`UPDATE content SET type = ${NOTE_TYPE} WHERE type IS NULL`); } + if (!meta || meta.user_version < 3) { + // The Android widget uses android.database.sqlite on this file. WAL + two SQLite stacks can + // corrupt the DB ("disk image is malformed"); DELETE mode is safe for both readers/writers. + await db.execAsync(`PRAGMA journal_mode = DELETE`); + } await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`); }; @@ -80,41 +94,51 @@ export const getNoteById = async (db: SQLiteDatabase, id: number): Promise { - return await db.runAsync('INSERT INTO content (title, note, type) VALUES (?, ?, ?)', [ + const result = await db.runAsync('INSERT INTO content (title, note, type) VALUES (?, ?, ?)', [ note.title, note.note, NOTE_TYPE, ]); + syncAndroidNoteListWidgetFromApp(); + return result; }; export const updateNote = async (db: SQLiteDatabase, id: number, note: NewNote) => { - return await db.runAsync('UPDATE content SET title = ?, note = ?, type = ? WHERE id = ?', [ + const result = await db.runAsync('UPDATE content SET title = ?, note = ?, type = ? WHERE id = ?', [ note.title, note.note, NOTE_TYPE, id, ]); + syncAndroidNoteListWidgetFromApp(); + return result; }; export const deletePosition = async (db: SQLiteDatabase, id: number) => { - return await db.runAsync('DELETE FROM content WHERE id = ?', [id]); + const result = await db.runAsync('DELETE FROM content WHERE id = ?', [id]); + syncAndroidNoteListWidgetFromApp(); + return result; }; export const addList = async (db: SQLiteDatabase, list: NewList) => { - return await db.runAsync('INSERT INTO content (title, note, type) VALUES (?, ?, ?)', [ + const result = await db.runAsync('INSERT INTO content (title, note, type) VALUES (?, ?, ?)', [ list.title, stringifyListItems(list.items), LIST_TYPE, ]); + syncAndroidNoteListWidgetFromApp(); + return result; }; export const updateList = async (db: SQLiteDatabase, id: number, list: NewList) => { - return await db.runAsync('UPDATE content SET title = ?, note = ?, type = ? WHERE id = ?', [ + const result = await db.runAsync('UPDATE content SET title = ?, note = ?, type = ? WHERE id = ?', [ list.title, stringifyListItems(list.items), LIST_TYPE, id, ]); + syncAndroidNoteListWidgetFromApp(); + return result; }; export const getListItemsById = async ( @@ -130,9 +154,11 @@ export const getListItemsById = async ( }; export const updateListItems = async (db: SQLiteDatabase, id: number, items: ListItem[]) => { - return await db.runAsync('UPDATE content SET note = ? WHERE id = ? AND type = ?', [ + const result = await db.runAsync('UPDATE content SET note = ? WHERE id = ? AND type = ?', [ stringifyListItems(items), id, LIST_TYPE, ]); + syncAndroidNoteListWidgetFromApp(); + return result; }; diff --git a/package-lock.json b/package-lock.json index 50c1125..f3878ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-notepad", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-notepad", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { "@react-navigation/native": "^7.0.0", "@rn-primitives/portal": "~1.3.0", diff --git a/package.json b/package.json index a7413e3..8ad1f41 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "simple-notepad", "main": "expo-router/entry", - "version": "1.2.1", + "version": "1.3.0", "scripts": { - "prebuild": "expo prebuild --clean", + "prebuild": "expo prebuild", "dev": "expo start", "android": "expo run:android", "ios": "expo run:ios",