diff --git a/README.md b/README.md index e462f58..a55d690 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ at the time; the last full run on the Simulator is under | [`sensors_plus_watchos`](packages/sensors_plus_watchos) [![pub](https://img.shields.io/pub/v/sensors_plus_watchos.svg)](https://pub.dev/packages/sensors_plus_watchos) | [`sensors_plus`](https://pub.dev/packages/sensors_plus) | CoreMotion (`CMMotionManager`) | | [`local_auth_watchos`](packages/local_auth_watchos) [![pub](https://img.shields.io/pub/v/local_auth_watchos.svg)](https://pub.dev/packages/local_auth_watchos) | [`local_auth`](https://pub.dev/packages/local_auth) | LocalAuthentication (passcode) | | [`geolocator_watchos`](packages/geolocator_watchos) [![pub](https://img.shields.io/pub/v/geolocator_watchos.svg)](https://pub.dev/packages/geolocator_watchos) | [`geolocator`](https://pub.dev/packages/geolocator) | CoreLocation (`CLLocationManager`) | +| [`sqflite_watchos`](packages/sqflite_watchos) | [`sqflite`](https://pub.dev/packages/sqflite) | The system SQLite (`libsqlite3`), its C API called from Dart | | [`video_player_watchos`](packages/video_player_watchos) [![pub](https://img.shields.io/pub/v/video_player_watchos.svg)](https://pub.dev/packages/video_player_watchos) | [`video_player`](https://pub.dev/packages/video_player) | AVFoundation + AVKit platform view | | [`audioplayers_watchos`](packages/audioplayers_watchos) [![pub](https://img.shields.io/pub/v/audioplayers_watchos.svg)](https://pub.dev/packages/audioplayers_watchos) | [`audioplayers`](https://pub.dev/packages/audioplayers) | AVFoundation (`AVPlayer`) | | [`in_app_purchase_watchos`](packages/in_app_purchase_watchos) [![pub](https://img.shields.io/pub/v/in_app_purchase_watchos.svg)](https://pub.dev/packages/in_app_purchase_watchos) | [`in_app_purchase`](https://pub.dev/packages/in_app_purchase) | StoreKit (`SKProductsRequest`, `SKPaymentQueue`) | @@ -116,11 +117,11 @@ watch** — plugins built on those are portable, not excluded. These have a watchOS-viable native backend and are good future additions; they are simply out of scope for now (large surface or partial support): -`sqflite` (SQLite), `flutter_tts` (AVFoundation) and `cloud_firestore` -(the Firebase Apple SDK ships Firestore's core as a prebuilt binary with no -watchOS slice, so a port needs Firebase's from-source Firestore build; the -rest of the network-viable Firebase family — core, auth, storage, -messaging — is ported above). +`flutter_tts` (AVFoundation) and `cloud_firestore` (the Firebase Apple SDK +ships Firestore's core as a prebuilt binary with no watchOS slice, so a +port needs Firebase's from-source Firestore build; the rest of the +network-viable Firebase family — core, auth, storage, messaging — is +ported above). ## Usage diff --git a/packages/sqflite_watchos/.gitignore b/packages/sqflite_watchos/.gitignore new file mode 100644 index 0000000..7b8ac9e --- /dev/null +++ b/packages/sqflite_watchos/.gitignore @@ -0,0 +1,23 @@ +# Dart / Flutter +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub/ + +# Xcode / SwiftPM (per-user, generated when watchos/Package.swift is opened) +**/.swiftpm/ +**/.build/ +**/xcuserdata/ + +# flutter-watchos app wiring (generated in an app's watchos/, never a plugin's) +watchos/Flutter/ + +# IDE +.idea/ +.vscode/ +*.iml + +# macOS +.DS_Store diff --git a/packages/sqflite_watchos/CHANGELOG.md b/packages/sqflite_watchos/CHANGELOG.md new file mode 100644 index 0000000..bb63e56 --- /dev/null +++ b/packages/sqflite_watchos/CHANGELOG.md @@ -0,0 +1,27 @@ +## 0.0.1 + +First version, staged: checked on the watchOS 27.0 Simulator, not yet on a +physical watch. + +* sqflite on the watch, on the system SQLite: `watchos/Package.swift` links + `libsqlite3`, and Dart calls its C API over dart:ffi. +* sqflite's method protocol answered in Dart: open, close, execute, insert, + update, query with paged cursors, batch with `noResult` and + `continueOnError`, transactions with transaction ids, database files and + bytes. `lib/src/handler.dart` is adapted from sqflite_common_ffi's + `sqflite_ffi_impl.dart` and carries its BSD-2-Clause notice. +* Arguments bind as on iOS: a `bool` as 1 or 0, a `Uint8List` or plain + `List` as a blob. +* A connection waits up to 2 seconds for another connection's lock, FMDB's + default on iOS. +* An in-memory database is never a single instance. +* `SqfliteWatchos` extends `SqflitePlatform`; `registerWith` makes its + `databaseFactoryWatchos` sqflite's default on watchOS. +* `sqflite_common: ">=2.5.14 <2.6.0"`: 2.5.14 fixes a hang when a cursor is + closed inside a transaction, and the `src/` imports stop below the next + minor. +* Two FFI exports: `sqflite_watchos_databases_path` and + `sqflite_watchos_sqlite_version`. +* The example's `lib/` is a small watch demo, because sqflite's own example + depends on the unpublished `sqflite_example_common`; its official + integration test is upstream's, unchanged. diff --git a/packages/sqflite_watchos/LICENSE b/packages/sqflite_watchos/LICENSE new file mode 100644 index 0000000..5385068 --- /dev/null +++ b/packages/sqflite_watchos/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2019, Alexandre Roux Tekartik +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/sqflite_watchos/PORTING_REPORT.md b/packages/sqflite_watchos/PORTING_REPORT.md new file mode 100644 index 0000000..cb9b1f1 --- /dev/null +++ b/packages/sqflite_watchos/PORTING_REPORT.md @@ -0,0 +1,90 @@ +# sqflite_watchos — porting report + +## Verification status + +| Aspect | Result | +|---|---| +| Implementation | ✅ Working (system SQLite over FFI) | +| watchOS capability | Full — every method of sqflite's protocol. The deprecated `Sqflite.*` debug helpers call the method channel directly and throw (see the README). Not yet checked on a physical watch | +| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API and the protocol below it | +| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (14 tests), against the watch's own `libsqlite3`. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits | +| Internal unified demo | not in the internal test app yet | + +Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. + +> This scaffold has been **finished by hand** into a working FFI package; see +> the status above. The generated notes below are kept for provenance. + +Generated by `flutter-watchos plugin port` on 2026-10-19. + +Source: `sqflite_darwin` 2.4.4+1 +Protocol handler adapted from: `sqflite_common_ffi` 2.4.4, `lib/src/sqflite_ffi_impl.dart` (BSD-2-Clause, Alexandre Roux Tekartik) +Base platform: ios (Objective-C) +Output: `./sqflite_watchos` + +## How this differs from sqflite_darwin + +sqflite_darwin is a method-channel plugin around FMDB. Method channels do +not run on watchOS, and FMDB is a wrapper the watch does not need, so this +package keeps none of that native code: + +- `lib/src/sqlite3.dart` binds the part of SQLite's C API sqflite needs + (`sqlite3_open_v2`, `_prepare_v2`, `_step`, the `_bind_*` and `_column_*` + functions, `_changes`, `_last_insert_rowid`, `_get_autocommit`, + `_busy_timeout`, the error calls). Arguments bind as sqflite_darwin binds + them: a `bool` as 1 or 0, a `Uint8List` or plain `List` as a blob. + Each connection waits up to 2 seconds for another connection's lock, + FMDB's default on iOS. On the watch the functions resolve from the + process, because `watchos/Package.swift` links the system `libsqlite3`; + host tests open the host's system library instead, so the same code runs + real SQL there. +- `lib/src/handler.dart` answers sqflite's method protocol: the same + methods, arguments and results the method channel carries on iOS. It is + adapted from sqflite_common_ffi's `lib/src/sqflite_ffi_impl.dart`, the + protocol's reference implementation outside a method channel, and carries + its copyright notice. That covers transaction ids (a call from outside + the open transaction waits for it), `transactionClosed` when SQLite rolls a + transaction back, paged cursors, batches with `continueOnError`, and + single-instance databases, never for `:memory:`, as in sqflite_darwin. +- `watchos/Classes/sqflite_watchos_ffi.m` has two helpers: the Documents + directory, and `sqlite3_libversion()`, which also keeps `libsqlite3` linked. +- sqflite federates through sqflite_common's default factory. `SqfliteWatchos` + extends `SqflitePlatform`, as `SqfliteDarwin` does, and its `registerWith` + sets `databaseFactoryOrNull` to `databaseFactoryWatchos`, a factory from + sqflite_common's `buildDatabaseFactory`, as sqflite_darwin sets its + method-channel factory. `buildDatabaseFactory` and + `SqfliteDatabaseException` come from sqflite_common's `src/` libraries, + which sqflite_common_ffi imports the same way; `src/` is not public API, so + `sqflite_common: ">=2.5.14 <2.6.0"` stops below the next minor. + +The one API the port tool flagged, `UIDevice` (the platform version), is +replaced by `Platform.operatingSystemVersion`. + +## Example + +sqflite's own example app is built on `sqflite_example_common`, which is +not published, so `example/lib/main.dart` is a small watch demo instead. +`example/integration_test/sqflite_test.dart` is upstream's test, unchanged but for +the formatting this repo's CI checks. + +## Protocol coverage + +| Method | watchOS | +|---|---| +| `openDatabase` | ✅ read-write (creating parent directories) or read-only; single instance by path, never for `:memory:` | +| `closeDatabase` | ✅ cancels operations queued behind a transaction; closing twice is fine | +| `execute` | ✅ several statements without arguments, one with; begins and ends transactions | +| `insert` | ✅ the new row id, or null when nothing was inserted (sqflite_common returns 0) | +| `update` | ✅ the changed row count | +| arguments, every method | ✅ null, int, `BigInt` within 64 bits, double, bool (1 or 0), String, `Uint8List` and `List` (a blob) | +| `query` / `queryCursorNext` | ✅ all rows, or pages for a cursor | +| `batch` | ✅ `noResult`, `continueOnError` | +| `getDatabasesPath` | ✅ the app's Documents directory | +| `databaseExists` / `deleteDatabase` | ✅ | +| `readDatabaseBytes` / `writeDatabaseBytes` | ✅ | +| `options` / `debugMode` | ✅ kept; nothing is logged | +| `getPlatformVersion` | ✅ `watchOS ` | + +--- + +See the finished plugins in the `flutterwatch/plugins` repo (e.g. `path_provider_watchos`, `battery_plus_watchos`) for worked examples of this exact shape. diff --git a/packages/sqflite_watchos/README.md b/packages/sqflite_watchos/README.md new file mode 100644 index 0000000..bde9f0e --- /dev/null +++ b/packages/sqflite_watchos/README.md @@ -0,0 +1,77 @@ +# sqflite_watchos + +The watchOS implementation of [`sqflite`](https://pub.dev/packages/sqflite). + +Databases are the **system SQLite**: watchOS ships `libsqlite3`, the +flutter-watchos CLI links it, and Dart calls its C API over dart:ffi. +sqflite's method protocol (open, execute, insert, update, query with cursors, +batch, transactions) is answered in Dart, the way `sqflite_common_ffi` +answers it on desktop, so the `Database` API behaves as it does on iOS and +Android. + +> Scaffolded by [`flutter-watchos plugin port`](https://github.com/flutterwatch/flutter-watchos) +> from `sqflite_darwin`, then implemented and verified by hand. + +## Usage + +This is a federated plugin implementation. Apps that already depend on +`sqflite` and target watchOS only need to add this package alongside it: + +```yaml +dependencies: + sqflite: ^2.4.4 + sqflite_watchos: ^0.0.1 +``` + +The plugin registers automatically via Flutter's federated registry — no +explicit imports required from app code. Use sqflite as usual: + +```dart +final Database db = await openDatabase( + 'laps.db', + version: 1, + onCreate: (Database db, int version) => + db.execute('CREATE TABLE lap (id INTEGER PRIMARY KEY, at INTEGER)'), +); +await db.insert('lap', {'at': DateTime.now().millisecondsSinceEpoch}); +``` + +## Behaviour on watchOS + +| sqflite | watchOS | +|---|---| +| `openDatabase` / `openReadOnlyDatabase` | supported; relative paths live in the app's Documents directory, as on iOS, and missing parent directories are created. A connection waits up to 2 seconds for another connection's lock, as on iOS | +| `execute` / `insert` / `update` / `delete` / `rawQuery` and friends | supported; integers are 64-bit, a `bool` argument is stored as 1 or 0, text is UTF-8, and a blob (a `Uint8List` or a `List` argument) comes back as `Uint8List`. sqflite_common still warns once per type, in debug, about `bool` and `List` arguments, as on every platform | +| `queryCursor` | supported, page by page | +| `batch` | supported, with `noResult` and `continueOnError` | +| `transaction` | supported; calls from outside an open transaction wait until it ends | +| `databaseExists` / `deleteDatabase` / `getDatabasesPath` | supported; deleting an open single-instance database closes it first, and the `-wal`, `-shm` and `-journal` files beside it go too, as on iOS | +| `readDatabaseBytes` / `writeDatabaseBytes` | supported | +| `DatabaseException` helpers | `isUniqueConstraintError`, `isNoSuchTableError`, `isSyntaxError`, `isOpenFailedError`, `isReadOnlyError`, `isDatabaseClosedError`; `getResultCode()` is SQLite's extended result code | + +SQL runs on the isolate that calls it, synchronously inside each `Future`. +On iOS sqflite runs it on a native background thread; on the watch a long +query holds up the frame it runs in, so keep heavy work small or move it to +an isolate of its own. + +`SqfliteWatchos.sqliteVersion` reports the system SQLite's version. + +## Not supported on watchOS + +Every method of sqflite's protocol is answered; `PORTING_REPORT.md` lists +each under "Protocol coverage". What is left: + +| sqflite | On watchOS | Why | +|---|---|---| +| `Sqflite.setDebugModeOn`, `Sqflite.devSetDebugModeOn`, `Sqflite.devSetOptions`, `Sqflite.devInvokeMethod` (all deprecated) | throw `MissingPluginException` | they call sqflite's method channel directly, not the database factory; `databaseFactory.setLogLevel` reaches this package | +| `androidSetLocale` | fails | Android only, as on iOS | + +`Sqflite.firstIntValue`, `Sqflite.hex` and `Sqflite.setLockWarningInfo` +run in Dart and work. The sqflite-specific `PRAGMA sqflite_…` statements +are accepted and ignored. + +## License + +BSD-2-Clause, as upstream sqflite's. `lib/src/handler.dart` is adapted from +sqflite_common_ffi and carries its copyright notice. See `LICENSE` for the +full text. diff --git a/packages/sqflite_watchos/analysis_options.yaml b/packages/sqflite_watchos/analysis_options.yaml new file mode 100644 index 0000000..1d83d30 --- /dev/null +++ b/packages/sqflite_watchos/analysis_options.yaml @@ -0,0 +1,19 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + public_member_api_docs: true diff --git a/packages/sqflite_watchos/example/.gitignore b/packages/sqflite_watchos/example/.gitignore new file mode 100644 index 0000000..3b47c00 --- /dev/null +++ b/packages/sqflite_watchos/example/.gitignore @@ -0,0 +1,14 @@ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml +.idea/ +.DS_Store + +# watchOS / CocoaPods +watchos/Pods/ +watchos/Podfile.lock +watchos/.symlinks/ +watchos/Flutter/Flutter.framework diff --git a/packages/sqflite_watchos/example/README.md b/packages/sqflite_watchos/example/README.md new file mode 100644 index 0000000..69ed5b7 --- /dev/null +++ b/packages/sqflite_watchos/example/README.md @@ -0,0 +1,16 @@ +# sqflite_example + +A watchOS-only example: laps saved to a SQLite database on the watch. Run it +on an Apple Watch simulator with: + +```sh +flutter-watchos run +``` + +`integration_test/sqflite_test.dart` is sqflite's official integration test, +unchanged. It uses `package:test`'s `test`, which `flutter-watchos drive` +does not collect, so run it with `test`: + +```sh +flutter-watchos test integration_test/sqflite_test.dart -d +``` diff --git a/packages/sqflite_watchos/example/analysis_options.yaml b/packages/sqflite_watchos/example/analysis_options.yaml new file mode 100644 index 0000000..801d885 --- /dev/null +++ b/packages/sqflite_watchos/example/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + exclude: + - build/** +include: package:flutter_lints/flutter.yaml diff --git a/packages/sqflite_watchos/example/integration_test/sqflite_test.dart b/packages/sqflite_watchos/example/integration_test/sqflite_test.dart new file mode 100644 index 0000000..1f4e919 --- /dev/null +++ b/packages/sqflite_watchos/example/integration_test/sqflite_test.dart @@ -0,0 +1,380 @@ +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' as io; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart' hide test; +import 'package:material_ui/material_ui.dart'; +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:test/test.dart' show test; + +// ignore_for_file: avoid_print +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + group('sqflite', () { + group('open', () { + test('missing directory', () async { + //await devVerbose(); + final path = join('test_missing_sub_dir', 'simple.db'); + try { + await Directory(join(await getDatabasesPath(), dirname(path))) + .delete(recursive: true); + } catch (_) {} + final db = await openDatabase( + path, + version: 1, + onCreate: (db, version) async { + expect(await db.getVersion(), 0); + }, + ); + expect(await db.getVersion(), 1); + await db.close(); + }); + test('failure', () { + // This one seems ignored + // fail('regular test failure'); + }); + test('in_memory', () async { + final db = await openDatabase( + inMemoryDatabasePath, + version: 1, + onCreate: (db, version) async { + expect(await db.getVersion(), 0); + }, + ); + expect(await db.getVersion(), 1); + await db.close(); + }); + }); + + test('exists', () async { + expect(await databaseExists(inMemoryDatabasePath), isFalse); + const path = 'test_exists.db'; + await deleteDatabase(path); + expect(await databaseExists(path), isFalse); + final db = await openDatabase(path); + try { + expect(await databaseExists(path), isTrue); + } finally { + await db.close(); + } + }); + test('close in transaction', () async { + // await Sqflite.devSetDebugModeOn(true); + const path = 'test_close_in_transaction.db'; + final factory = databaseFactory; + await deleteDatabase(path); + var db = await factory.openDatabase( + path, + options: OpenDatabaseOptions(version: 1), + ); + try { + await db.execute('BEGIN TRANSACTION'); + await db.close(); + + db = await factory.openDatabase( + path, + options: OpenDatabaseOptions(version: 1), + ); + } finally { + await db.close(); + } + }); + + /// Check if a file is a valid database file + /// + /// An empty file is a valid empty sqlite file + Future isDatabase(String path) async { + var isDatabase = false; + Database? db; + try { + db = await openReadOnlyDatabase(path); + //await db.getVersion(); + await db.query('sqlite_master'); + isDatabase = true; + } catch (_) { + } finally { + await db?.close(); + } + return isDatabase; + } + + test('read_only missing database', () async { + const path = 'test_missing_database.db'; + await deleteDatabase(path); + try { + final db = await openReadOnlyDatabase(path); + fail('should fail ${db.path}'); + } on DatabaseException catch (_) {} + + expect(await isDatabase(path), isFalse); + }); + + test('read_only empty file', () async { + const path = 'empty_file_database.db'; + await deleteDatabase(path); + final fullPath = join((await getDatabasesPath()), path); + await Directory(dirname(fullPath)).create(recursive: true); + await File(fullPath).writeAsString(''); + + // Open is fine, that is the native behavior + final db = await openReadOnlyDatabase(fullPath); + expect(await File(fullPath).readAsString(), ''); + + await db.getVersion(); + + await db.close(); + expect(await File(fullPath).readAsString(), ''); + expect(await isDatabase(fullPath), isTrue); + }); + + test('read_only missing bad format', () async { + const path = 'test_bad_format_database.db'; + await deleteDatabase(path); + final fullPath = join((await getDatabasesPath()), path); + await Directory(dirname(fullPath)).create(recursive: true); + await File(fullPath).writeAsString('test'); + + // Open is fine, that is the native behavior + final db = await openReadOnlyDatabase(fullPath); + print('DEBUG'); + expect(await File(fullPath).readAsString(), 'test'); + try { + final version = await db.getVersion(); + + print(await db.query('sqlite_master')); + fail('getVersion should fail ${db.path} $version'); + } on DatabaseException catch (_) { + // Android: DatabaseException(file is not a database (code 26 SQLITE_NOTADB)) sql 'PRAGMA user_version' args []} + } + await db.close(); + expect(await File(fullPath).readAsString(), 'test'); + + expect(await isDatabase(fullPath), isFalse); + expect(await isDatabase(fullPath), isFalse); + + expect(await File(fullPath).readAsString(), 'test'); + }); + + test('multiple database', () async { + //await Sqflite.devSetDebugModeOn(true); + const count = 10; + final dbs = List.filled(count, null, growable: false); + for (var i = 0; i < count; i++) { + final path = 'test_multiple_$i.db'; + await deleteDatabase(path); + dbs[i] = await openDatabase( + path, + version: 1, + onCreate: (db, version) async { + await db.execute( + 'CREATE TABLE Test (id INTEGER PRIMARY KEY, name TEXT)', + ); + expect( + await db.rawInsert('INSERT INTO Test (name) VALUES (?)', [ + 'test_$i', + ]), + 1, + ); + }, + ); + } + + for (var i = 0; i < count; i++) { + final db = dbs[i]!; + try { + final name = (await db.query('Test', columns: ['name'])) + .first + .values + .first as String?; + expect(name, 'test_$i'); + } finally { + await db.close(); + } + } + + for (var i = 0; i < count; i++) { + final db = dbs[i]!; + await db.close(); + } + }); + + test('version', () async { + // await Sqflite.devSetDebugModeOn(true); + const path = 'test_version.db'; + await deleteDatabase(path); + var db = await openDatabase(path, version: 1); + try { + expect(await db.getVersion(), 1); + unawaited(db.close()); + + db = await openDatabase(path, version: 2); + expect(await db.getVersion(), 2); + unawaited(db.close()); + + db = await openDatabase(path, version: 1); + expect(await db.getVersion(), 1); + unawaited(db.close()); + + db = await openDatabase(path, version: 1); + expect(await db.getVersion(), 1); + expect(await isDatabase(path), isTrue); + } finally { + await db.close(); + } + expect(await isDatabase(path), isTrue); + }); + + test('duplicated_column', () async { + // await Sqflite.devSetDebugModeOn(true); + const path = 'test_duplicated_column.db'; + await deleteDatabase(path); + final db = await openDatabase(path); + try { + await db.execute('CREATE TABLE Test (col1 INTEGER, col2 INTEGER)'); + await db.insert('Test', {'col1': 1, 'col2': 2}); + + final result = await db.rawQuery( + 'SELECT t.col1, col1, t.col2, col2 AS col1 FROM Test AS t', + ); + expect(result, [ + {'col1': 2, 'col2': 2}, + ]); + } finally { + await db.close(); + } + }); + + test('indexed_param', () async { + final db = await openDatabase(':memory:'); + expect(await db.rawQuery('SELECT ?1 + ?2', [3, 4]), [ + {'?1 + ?2': 7}, + ]); + await db.close(); + }); + + test('deleteDatabase', () async { + // await devVerbose(); + const path = 'test_delete_database.db'; + await deleteDatabase(path); + expect(await databaseExists(path), isFalse); + + late Database db; + try { + db = await openDatabase(path); + expect(await db.getVersion(), 0); + await db.setVersion(1); + await db.close(); + expect(await databaseExists(path), isTrue); + + await deleteDatabase(path); + expect(await databaseExists(path), isFalse); + + db = await openDatabase(path); + expect(await db.getVersion(), 0); + await db.setVersion(1); + + // delete without closing every time + await deleteDatabase(path); + db = await openDatabase(path); + expect(await db.getVersion(), 0); + await db.execute('BEGIN TRANSACTION'); + await db.setVersion(2); + + await deleteDatabase(path); + db = await openDatabase(path); + expect(await db.getVersion(), 0); + await db.setVersion(3); + + await deleteDatabase(path); + db = await openDatabase(path); + expect(await db.getVersion(), 0); + } finally { + await db.close(); + } + }); + test('androidSetLocale', () async { + if (!kIsWeb && io.Platform.isAndroid) { + const path = 'test_android_set_locale.db'; + await deleteDatabase(path); + late Database db; + + db = await openDatabase( + path, + onConfigure: (db) async { + await db.androidSetLocale('zh-CN'); + }, + version: 1, + onCreate: (db, v) async { + await db.execute('CREATE TABLE Test(name TEXT)'); + for (var row in >[ + {'name': '桌'}, + {'name': '椅'}, + {'name': '盘'}, + ]) { + await db.insert('Test', row); + } + }, + ); + Future> getNames() async { + return (await db.query( + 'Test', + orderBy: 'name COLLATE LOCALIZED ASC', + )) + .map((e) => e.values.first!.toString()) + .toList(); + } + + try { + // Order ok + expect(await getNames(), ['盘', '椅', '桌']); + + // Reopen same locale + await db.close(); + db = await openDatabase( + path, + onConfigure: (db) async { + await db.androidSetLocale('zh-CN'); + }, + ); + // order ok too + expect(await getNames(), ['盘', '椅', '桌']); + + await db.close(); + // No locale + db = await openDatabase(path); + // order not ok + expect(await getNames(), ['桌', '椅', '盘']); + + await db.close(); + db = await openDatabase( + path, + onConfigure: (db) async { + await db.androidSetLocale('en-US'); + }, + ); + // order not ok + expect(await getNames(), ['桌', '椅', '盘']); + + await db.close(); + db = await openDatabase( + path, + onConfigure: (db) async { + await db.androidSetLocale('zh-CN'); + }, + ); + // order ok again + expect(await getNames(), ['盘', '椅', '桌']); + } finally { + await db.close(); + } + } + }); + }); +} diff --git a/packages/sqflite_watchos/example/lib/main.dart b/packages/sqflite_watchos/example/lib/main.dart new file mode 100644 index 0000000..292ec95 --- /dev/null +++ b/packages/sqflite_watchos/example/lib/main.dart @@ -0,0 +1,112 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// sqflite's own example app is built on sqflite_example_common, which is not +// published, so this watch-sized demo stands in for it. The official +// integration test in integration_test/ is upstream's, unchanged. + +import 'package:flutter/material.dart'; +import 'package:sqflite/sqflite.dart'; + +void main() => runApp(const SqfliteExampleApp()); + +/// Laps saved to a SQLite database on the watch. +class SqfliteExampleApp extends StatelessWidget { + /// The example app. + const SqfliteExampleApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: const LapsPage(), + ); +} + +/// Add, list and clear laps. +class LapsPage extends StatefulWidget { + /// The one page. + const LapsPage({super.key}); + + @override + State createState() => _LapsPageState(); +} + +class _LapsPageState extends State { + Database? _db; + List> _laps = const >[]; + String? _error; + + @override + void initState() { + super.initState(); + _open(); + } + + @override + void dispose() { + _db?.close(); + super.dispose(); + } + + Future _open() async { + try { + _db = await openDatabase( + 'laps.db', + version: 1, + onCreate: (Database db, int version) => db.execute( + 'CREATE TABLE lap (id INTEGER PRIMARY KEY, at INTEGER NOT NULL)'), + ); + await _reload(); + } on DatabaseException catch (e) { + setState(() => _error = '$e'); + } + } + + Future _reload() async { + final List> laps = + await _db!.query('lap', orderBy: 'id DESC'); + setState(() => _laps = laps); + } + + Future _add() async { + await _db!.insert('lap', { + 'at': DateTime.now().millisecondsSinceEpoch, + }); + await _reload(); + } + + Future _clear() async { + await _db!.delete('lap'); + await _reload(); + } + + String _time(Object? millis) { + final DateTime t = DateTime.fromMillisecondsSinceEpoch(millis! as int); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; + } + + @override + Widget build(BuildContext context) => Scaffold( + body: ListView( + padding: const EdgeInsets.fromLTRB(12, 28, 12, 24), + children: [ + Text('${_laps.length} laps', + style: + const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)), + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.orangeAccent)), + FilledButton( + onPressed: _db == null ? null : _add, + child: const Text('Add lap'), + ), + for (final Map lap in _laps.take(5)) + Text('#${lap['id']} ${_time(lap['at'])}'), + if (_laps.isNotEmpty) + TextButton(onPressed: _clear, child: const Text('Clear')), + ], + ), + ); +} diff --git a/packages/sqflite_watchos/example/pubspec.yaml b/packages/sqflite_watchos/example/pubspec.yaml new file mode 100644 index 0000000..ef6fa26 --- /dev/null +++ b/packages/sqflite_watchos/example/pubspec.yaml @@ -0,0 +1,28 @@ +name: sqflite_example +description: "watchOS example for sqflite_watchos." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ">=3.1.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + sqflite: ^2.4.4 + sqflite_watchos: + path: ../ + path: ^1.9.0 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^4.0.0 + # The official integration test imports these two. + material_ui: ^1.5.0 + test: ^1.25.0 + +flutter: + uses-material-design: true diff --git a/packages/sqflite_watchos/example/test_driver/integration_test.dart b/packages/sqflite_watchos/example/test_driver/integration_test.dart new file mode 100644 index 0000000..2acb97e --- /dev/null +++ b/packages/sqflite_watchos/example/test_driver/integration_test.dart @@ -0,0 +1,4 @@ +// Generated by `flutter-watchos plugin port --include-example`. +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/sqflite_watchos/example/watchos/Flutter/.gitignore b/packages/sqflite_watchos/example/watchos/Flutter/.gitignore new file mode 100644 index 0000000..196f947 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Flutter/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts placed here by `flutter-watchos build` — never commit. +* +!.gitignore diff --git a/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json b/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/sqflite_watchos/example/watchos/HostApp/Info.plist b/packages/sqflite_watchos/example/watchos/HostApp/Info.plist new file mode 100644 index 0000000..3dd42a5 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/HostApp/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Sqflite Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + ITSWatchOnlyContainer + + LSApplicationLaunchProhibited + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/project.pbxproj b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3d9e382 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,438 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AB0000000000000000000001 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000001 /* App.swift */; }; + AB0000000000000000000003 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000003 /* Flutter.framework */; }; + AB0000000000000000000004 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000003 /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + AB0000000000000000000005 /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000004 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + AB0000000000000000000006 /* flutter_assets in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000005 /* flutter_assets */; }; + AB0000000000000000000007 /* icudtl.dat in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000006 /* icudtl.dat */; }; + AB0000000000000000000008 /* vm_isolate_snapshot.bin in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000A /* vm_isolate_snapshot.bin */; }; + AB0000000000000000000009 /* isolate_snapshot.bin in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000B /* isolate_snapshot.bin */; }; + AB0000000000000000000010 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000C /* Assets.xcassets */; }; + CB0000000000000000000002 /* Runner.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000007 /* Runner.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + CB0000000000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CF0000000000000000000004 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CA0000000000000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000002 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA0000000000000000000001; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + AC0000000000000000000001 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + AB0000000000000000000004 /* Flutter.framework in Embed Frameworks */, + AB0000000000000000000005 /* App.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + CD0000000000000000000011 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + CB0000000000000000000002 /* Runner.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0B66C03D2FE326E4002C6E54 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AF0000000000000000000001 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + AF0000000000000000000003 /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Flutter.framework; sourceTree = ""; }; + AF0000000000000000000004 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = App.framework; sourceTree = ""; }; + AF0000000000000000000005 /* flutter_assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = flutter_assets; sourceTree = ""; }; + AF0000000000000000000006 /* icudtl.dat */ = {isa = PBXFileReference; lastKnownFileType = file; path = icudtl.dat; sourceTree = ""; }; + AF0000000000000000000007 /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AF000000000000000000000A /* vm_isolate_snapshot.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = vm_isolate_snapshot.bin; sourceTree = ""; }; + AF000000000000000000000B /* isolate_snapshot.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = isolate_snapshot.bin; sourceTree = ""; }; + AF000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AF000000000000000000000D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AF000000000000000000000E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + CF0000000000000000000001 /* sqflite_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = sqflite_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CF0000000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CF0000000000000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AD0000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000003 /* Flutter.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AE0000000000000000000001 = { + isa = PBXGroup; + children = ( + AE0000000000000000000002 /* Runner */, + CE0000000000000000000001 /* HostApp */, + AE0000000000000000000004 /* Flutter */, + AE0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + AE0000000000000000000002 /* Runner */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000001 /* App.swift */, + AF000000000000000000000C /* Assets.xcassets */, + 0B66C03D2FE326E4002C6E54 /* Info.plist */, + ); + path = Runner; + sourceTree = ""; + }; + AE0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000007 /* Runner.app */, + CF0000000000000000000001 /* sqflite_example.app */, + ); + name = Products; + sourceTree = ""; + }; + AE0000000000000000000004 /* Flutter */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000003 /* Flutter.framework */, + AF0000000000000000000004 /* App.framework */, + AF0000000000000000000005 /* flutter_assets */, + AF0000000000000000000006 /* icudtl.dat */, + AF000000000000000000000A /* vm_isolate_snapshot.bin */, + AF000000000000000000000B /* isolate_snapshot.bin */, + AF000000000000000000000D /* Debug.xcconfig */, + AF000000000000000000000E /* Release.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + CE0000000000000000000001 /* HostApp */ = { + isa = PBXGroup; + children = ( + CF0000000000000000000004 /* Assets.xcassets */, + CF0000000000000000000003 /* Info.plist */, + ); + path = HostApp; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000001 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = BA0000000000000000000002 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + AD0000000000000000000002 /* Sources */, + AD0000000000000000000001 /* Frameworks */, + AD0000000000000000000003 /* Resources */, + AC0000000000000000000001 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = AF0000000000000000000007 /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + CA0000000000000000000001 /* HostApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA0000000000000000000003 /* Build configuration list for PBXNativeTarget "HostApp" */; + buildPhases = ( + CD0000000000000000000012 /* Resources */, + CD0000000000000000000011 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + CA0000000000000000000002 /* PBXTargetDependency */, + ); + name = HostApp; + productName = sqflite_example; + productReference = CF0000000000000000000001 /* sqflite_example.app */; + productType = "com.apple.product-type.application.watchapp2-container"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000002 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + TargetAttributes = { + AA0000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + CA0000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = BA0000000000000000000001 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE0000000000000000000001; + productRefGroup = AE0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000001 /* Runner */, + CA0000000000000000000001 /* HostApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AD0000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000006 /* flutter_assets in Resources */, + AB0000000000000000000007 /* icudtl.dat in Resources */, + AB0000000000000000000008 /* vm_isolate_snapshot.bin in Resources */, + AB0000000000000000000009 /* isolate_snapshot.bin in Resources */, + AB0000000000000000000010 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CD0000000000000000000012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CB0000000000000000000003 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AD0000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000001 /* App.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CA0000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA0000000000000000000001 /* Runner */; + targetProxy = CA0000000000000000000004 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + BB0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + BB0000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000D /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + INFOPLIST_FILE = Runner/Info.plist; + INSTALL_PATH = "$(LOCAL_APPS_DIR)"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.sqfliteExample.watchkitapp"; + PRODUCT_NAME = Runner; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BB0000000000000000000003 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = s; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + BB0000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + INFOPLIST_FILE = Runner/Info.plist; + INSTALL_PATH = "$(LOCAL_APPS_DIR)"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.sqfliteExample.watchkitapp"; + PRODUCT_NAME = Runner; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + CC0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000D /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HostApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.sqfliteExample"; + PRODUCT_NAME = sqflite_example; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CC0000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HostApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.sqfliteExample"; + PRODUCT_NAME = sqflite_example; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BA0000000000000000000001 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000001 /* Debug */, + BB0000000000000000000003 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BA0000000000000000000002 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000002 /* Debug */, + BB0000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CA0000000000000000000003 /* Build configuration list for PBXNativeTarget "HostApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC0000000000000000000001 /* Debug */, + CC0000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000002 /* Project object */; +} diff --git a/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..fd1accd --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/sqflite_example.xcscheme b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/sqflite_example.xcscheme new file mode 100644 index 0000000..f301338 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/sqflite_example.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/sqflite_watchos/example/watchos/Runner/App.swift b/packages/sqflite_watchos/example/watchos/Runner/App.swift new file mode 100644 index 0000000..1fb6589 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner/App.swift @@ -0,0 +1,57 @@ +import SwiftUI + +#if !arch(arm64_32) +// The Flutter host (frame display, input, native overlays) — compiled by +// flutter-watchos into watchos/Flutter/, like Flutter.framework itself. +import FlutterWatchOS +#endif + +@main +struct SqfliteExampleApp: App { + #if !arch(arm64_32) + // Remote-notification plumbing (APNs device token, notification payloads) + // for plugins that need it, e.g. firebase_messaging. Safe to keep even if + // no plugin uses notifications. + @WKApplicationDelegateAdaptor(FlutterWatchOSAppDelegate.self) + private var flutterAppDelegate + + init() { + // Native platform views: register a SwiftUI factory for every + // `viewType` your Dart code embeds with WatchPlatformView + // (package:flutter_watchos). Example: + // + // WatchPlatformViewRegistry.register("my-gauge") { params in + // AnyView(MyGaugeView(params: params)) + // } + } + #endif + + var body: some Scene { + WindowGroup { + #if arch(arm64_32) + // 32-bit watches (Series 4–8 / SE) are not supported. + UnsupportedDeviceView() + #else + // Holds a black launch placeholder over the screen until Flutter's + // first frame is ready, then fades it out. Supply your own to + // replace it: `FlutterHostView { MyLaunchScreen() }`. + FlutterHostView() + #endif + } + } +} + +// Shown on 32-bit watches (Series 4–8 / SE), which this app does not support. +struct UnsupportedDeviceView: View { + var body: some View { + VStack(spacing: 8) { + Text("Sqflite Example") + .font(.headline) + Text("Requires Apple Watch Series 9 or later.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() + } +} diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..287d73c --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,140 @@ +{ + "images" : [ + { + "filename" : "icon_24x24_2x_38mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "icon_27.5x27.5_2x_42mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "icon_29x29_2x_companion.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "icon_29x29_3x_companion.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "icon_33x33_2x_45mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "icon_40x40_2x_38mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "icon_44x44_2x_40mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "icon_46x46_2x_41mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "icon_50x50_2x_44mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "icon_51x51_2x_45mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "icon_54x54_2x_49mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "icon_86x86_2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "icon_98x98_2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "icon_108x108_2x_44mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "icon_117x117_2x_45mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "icon_129x129_2x_49mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "icon_1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..819d623 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png new file mode 100644 index 0000000..7b3140c Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_117x117_2x_45mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_117x117_2x_45mm.png new file mode 100644 index 0000000..66f8d8c Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_117x117_2x_45mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_129x129_2x_49mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_129x129_2x_49mm.png new file mode 100644 index 0000000..0382bad Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_129x129_2x_49mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_24x24_2x_38mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_24x24_2x_38mm.png new file mode 100644 index 0000000..14682e5 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_24x24_2x_38mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_27.5x27.5_2x_42mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_27.5x27.5_2x_42mm.png new file mode 100644 index 0000000..e29fe11 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_27.5x27.5_2x_42mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png new file mode 100644 index 0000000..796aa3f Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_3x_companion.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_3x_companion.png new file mode 100644 index 0000000..afbe411 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_3x_companion.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_33x33_2x_45mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_33x33_2x_45mm.png new file mode 100644 index 0000000..c24aca9 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_33x33_2x_45mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_40x40_2x_38mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_40x40_2x_38mm.png new file mode 100644 index 0000000..011934e Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_40x40_2x_38mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png new file mode 100644 index 0000000..ec54e61 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_46x46_2x_41mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_46x46_2x_41mm.png new file mode 100644 index 0000000..d574b52 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_46x46_2x_41mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_50x50_2x_44mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_50x50_2x_44mm.png new file mode 100644 index 0000000..fafa3ef Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_50x50_2x_44mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_51x51_2x_45mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_51x51_2x_45mm.png new file mode 100644 index 0000000..883c37b Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_51x51_2x_45mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png new file mode 100644 index 0000000..457d76a Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png new file mode 100644 index 0000000..28feaed Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png new file mode 100644 index 0000000..3b901e5 Binary files /dev/null and b/packages/sqflite_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png differ diff --git a/packages/sqflite_watchos/example/watchos/Runner/Info.plist b/packages/sqflite_watchos/example/watchos/Runner/Info.plist new file mode 100644 index 0000000..d45c2e6 --- /dev/null +++ b/packages/sqflite_watchos/example/watchos/Runner/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Sqflite Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + sqflite_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + WKApplication + + WKWatchOnly + + + diff --git a/packages/sqflite_watchos/lib/sqflite_watchos.dart b/packages/sqflite_watchos/lib/sqflite_watchos.dart new file mode 100644 index 0000000..f70922e --- /dev/null +++ b/packages/sqflite_watchos/lib/sqflite_watchos.dart @@ -0,0 +1,77 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// watchOS implementation of `sqflite`, over dart:ffi. +// +// Method-channel plugins are not supported on watchOS, so sqflite's method +// protocol is answered in Dart (`src/handler.dart`) over the system SQLite's +// C API (`src/sqlite3.dart`). sqflite federates through sqflite_common's +// default database factory: `registerWith` sets it, as sqflite_darwin, which +// also extends SqflitePlatform, does with its method-channel factory. + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +// ignore: implementation_imports +import 'package:sqflite_common/src/mixin/factory.dart' + show buildDatabaseFactory; +import 'package:sqflite_common/sqflite.dart'; +import 'package:sqflite_platform_interface/sqflite_platform_interface.dart'; + +import 'src/handler.dart'; +import 'src/sqlite3.dart'; + +/// watchOS implementation of `sqflite`. +class SqfliteWatchos extends SqflitePlatform { + /// Test hook: the library SQLite's C API resolves from, instead of the + /// process. Host tests pass the host's system SQLite. + static DynamicLibrary? libraryOverride; + + /// Test hook: where relative database paths live, instead of the app's + /// Documents directory. + static String? databasesPathOverride; + + static DatabaseFactory? _factory; + + /// The database factory that answers sqflite's calls on the watch, as + /// [SqflitePlatform.databaseFactoryMethodChannel] answers them on iOS. + static DatabaseFactory get databaseFactoryWatchos => _factory ??= () { + final DynamicLibrary library = + libraryOverride ?? DynamicLibrary.process(); + final SqfliteWatchosHandler handler = SqfliteWatchosHandler( + Sqlite3Library(library), + databasesPath: databasesPathOverride ?? _databasesPath(library), + platformVersion: 'watchOS ${Platform.operatingSystemVersion}', + ); + return buildDatabaseFactory( + tag: 'watchos', + invokeMethod: handler.handle, + ); + }(); + + /// Registers this implementation as sqflite's default database factory on + /// watchOS, unless the app already set one. + static void registerWith() { + databaseFactoryOrNull ??= databaseFactoryWatchos; + } + + /// Forgets the factory so the next use builds a new one, for tests that + /// change [libraryOverride] or [databasesPathOverride]. + static void resetForTesting() => _factory = null; + + static String _databasesPath(DynamicLibrary library) => library + .lookupFunction Function(), Pointer Function()>( + 'sqflite_watchos_databases_path')() + .toDartString(); + + /// The version of the SQLite the watch runs, for example `3.46.1`. + static String get sqliteVersion => + (libraryOverride ?? DynamicLibrary.process()) + .lookupFunction Function(), Pointer Function()>( + libraryOverride == null + ? 'sqflite_watchos_sqlite_version' + : 'sqlite3_libversion')() + .toDartString(); +} diff --git a/packages/sqflite_watchos/lib/src/handler.dart b/packages/sqflite_watchos/lib/src/handler.dart new file mode 100644 index 0000000..46cc4e8 --- /dev/null +++ b/packages/sqflite_watchos/lib/src/handler.dart @@ -0,0 +1,575 @@ +// Copyright (c) 2019, Alexandre Roux Tekartik. All rights reserved. +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license that can +// be found in the LICENSE file. +// +// sqflite's method protocol, answered in Dart over SQLite's C API. +// +// Adapted from sqflite_common_ffi's lib/src/sqflite_ffi_impl.dart, the +// reference implementation of the protocol outside a method channel: its +// structure, its error messages, and its handling of transaction ids, +// operations queued behind another transaction, closed database ids, paged +// cursors, and batches that continue on error. Here SQLite is the system's, +// bound in sqlite3.dart, instead of package:sqlite3. +// +// sqflite_common drives every platform through one call, +// `invokeMethod(method, arguments)`: on iOS and Android it crosses a method +// channel, here it lands in [SqfliteWatchosHandler.handle]. The methods and +// the shape of their arguments and results are sqflite's own. + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +// ignore: implementation_imports +import 'package:sqflite_common/src/exception.dart' + show SqfliteDatabaseException; + +import 'sqlite3.dart'; + +/// `inMemoryDatabasePath`. +const String _inMemory = ':memory:'; + +/// `paramTransactionIdValueForce`: run even though another transaction is +/// open, as sqflite_common does to roll back a recovered one. +const int _forceTransactionId = -1; + +/// Statements sqflite reserves for itself, which go to no database. +const String _sqflitePragmaPrefix = 'PRAGMA sqflite_'; + +/// One open database and what is in flight on it. +class _Database { + _Database({ + required this.id, + required this.connection, + required this.path, + required this.readOnly, + required this.singleInstance, + }); + + final int id; + final SqliteConnection connection; + final String path; + final bool readOnly; + final bool singleInstance; + + int _lastTransactionId = 0; + int? currentTransactionId; + + /// Operations from outside the current transaction, run once it ends. + final List queued = []; + final List cancels = []; + + final Map cursors = {}; + int _lastCursorId = 0; + + Map toDebugMap() => { + 'path': path, + 'id': id, + 'readOnly': readOnly, + 'singleInstance': singleInstance, + }; + + void closeCursor(int cursorId) => cursors.remove(cursorId)?.statement.close(); + + /// Runs queued operations until one opens a transaction again. + void runQueued() { + while (queued.isNotEmpty && currentTransactionId == null) { + cancels.removeAt(0); + queued.removeAt(0)(); + } + } + + void close() { + for (final void Function() cancel in cancels) { + cancel(); + } + queued.clear(); + cancels.clear(); + for (final int id in cursors.keys.toList()) { + closeCursor(id); + } + connection.close(); + } +} + +class _Cursor { + _Cursor(this.id, this.statement, this.pageSize); + + final int id; + final SqliteStatement statement; + final int pageSize; +} + +/// Answers sqflite's method calls for every database this isolate opened. +class SqfliteWatchosHandler { + /// A handler over [library], keeping relative paths under + /// [databasesPath] and reporting [platformVersion]. + SqfliteWatchosHandler( + this.library, { + required this.databasesPath, + required this.platformVersion, + }); + + /// The SQLite C API. + final Sqlite3Library library; + + /// Where relative database paths live. + final String databasesPath; + + /// What `getPlatformVersion` answers. + final String platformVersion; + + final Map _databases = {}; + final Map _singleInstances = {}; + int _lastId = 0; + + /// sqflite's log level, as set through `options`. Kept for parity; this + /// handler logs nothing itself. + int logLevel = 0; + + /// Runs one sqflite method call. + Future handle(String method, [Object? arguments]) async { + final Map args = arguments is Map + ? arguments + : const {}; + switch (method) { + case 'openDatabase': + return _guard(() => _open(args), sql: null, arguments: null); + case 'closeDatabase': + return _guard(() => _close(args), sql: null, arguments: null); + case 'execute': + return _onDatabase(args, (_Database db) => _execute(db, args)); + case 'insert': + return _onDatabase(args, (_Database db) => _insert(db, args)); + case 'update': + return _onDatabase(args, (_Database db) => _update(db, args)); + case 'query': + return _onDatabase(args, (_Database db) => _query(db, args)); + case 'queryCursorNext': + return _onDatabase(args, (_Database db) => _cursorNext(db, args)); + case 'batch': + return _onDatabase(args, (_Database db) => _batch(db, args)); + case 'getDatabasesPath': + return databasesPath; + case 'getPlatformVersion': + return platformVersion; + case 'databaseExists': + final String path = _path(args); + return path != _inMemory && File(path).existsSync(); + case 'deleteDatabase': + _delete(_path(args)); + return null; + case 'readDatabaseBytes': + return {'bytes': File(_path(args)).readAsBytesSync()}; + case 'writeDatabaseBytes': + final File file = File(_path(args)); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(args['bytes']! as Uint8List); + return null; + case 'options': + final Object? level = args['logLevel']; + if (level is int) { + logLevel = level; + } + return null; + case 'debugMode': + if (arguments == true) { + logLevel = 2; // sqfliteLogLevelVerbose + } + return null; + default: + throw ArgumentError('Invalid method $method'); + } + } + + // ---- Paths and databases ------------------------------------------------ + + String _resolve(String path) => path == _inMemory || p.isAbsolute(path) + ? path + : p.join(databasesPath, path); + + String _path(Map args) => _resolve(args['path']! as String); + + bool _isClosedId(Object? id) => + id is int && id > 0 && id <= _lastId && !_databases.containsKey(id); + + _Database _databaseOrThrow(Map args) { + final Object? id = args['id']; + final _Database? db = _databases[id]; + if (db != null) { + return db; + } + if (_isClosedId(id)) { + // Matches sqflite_common's isDatabaseClosedError. + throw StateError('This database has already been closed'); + } + throw StateError('Database $id not found'); + } + + Map _open(Map args) { + final String path = _path(args); + final bool readOnly = args['readOnly'] == true; + // An in-memory database is never shared, as in sqflite_darwin: each + // open is a database of its own. + final bool singleInstance = + args['singleInstance'] == true && path != _inMemory; + if (singleInstance) { + final _Database? existing = _singleInstances[path]; + if (existing != null) { + return {'id': existing.id}; + } + } + if (!readOnly && path != _inMemory) { + // sqflite creates the missing parent directories on every platform. + Directory(p.dirname(path)).createSync(recursive: true); + } + final SqliteConnection connection; + try { + connection = library.open( + path, + readOnly + ? SqliteCode.openReadOnly + : SqliteCode.openReadWrite | SqliteCode.openCreate, + ); + } on SqliteFailure catch (failure) { + throw SqfliteDatabaseException( + 'open_failed: $failure', + {'path': path}, + resultCode: failure.extendedCode, + ); + } + final int id = ++_lastId; + final _Database db = _Database( + id: id, + connection: connection, + path: path, + readOnly: readOnly, + singleInstance: singleInstance, + ); + _databases[id] = db; + if (singleInstance) { + _singleInstances[path] = db; + } + return {'id': id}; + } + + Object? _close(Map args) { + if (_isClosedId(args['id'])) { + // Already closed, as a single instance deleted while open is. + return null; + } + final _Database db = _databaseOrThrow(args); + _forget(db); + return null; + } + + void _forget(_Database db) { + _databases.remove(db.id); + if (identical(_singleInstances[db.path], db)) { + _singleInstances.remove(db.path); + } + db.close(); + } + + void _delete(String path) { + final _Database? open = _singleInstances[path]; + if (open != null) { + _forget(open); + } + if (path == _inMemory) { + return; + } + // With the files SQLite keeps beside it, as sqflite_darwin deletes them: + // a write-ahead log left behind would be replayed into a new database + // created at the same path. + for (final String suffix in const [ + '', + '-wal', + '-shm', + '-journal' + ]) { + try { + final File file = File('$path$suffix'); + if (file.existsSync()) { + file.deleteSync(); + } + } catch (_) { + // As on every platform: deleting what cannot be deleted is not an + // error. + } + } + } + + // ---- Running SQL -------------------------------------------------------- + + /// Runs [action] on the database the call names, or queues it while + /// another transaction is open on that database. + Future _onDatabase( + Map args, + Object? Function(_Database db) action, + ) { + final _Database db; + try { + db = _databaseOrThrow(args); + } catch (e) { + return Future.error(_wrap(e, null, args)); + } + final Object? transactionId = args['transactionId']; + if (db.currentTransactionId == null) { + return Future.sync(() => _run(db, args, action)); + } + if (transactionId == db.currentTransactionId || + transactionId == _forceTransactionId) { + try { + return Future.value(_run(db, args, action)); + } catch (e, st) { + return Future.error(e, st); + } finally { + if (db.currentTransactionId == null) { + scheduleMicrotask(db.runQueued); + } + } + } + final Completer completer = Completer(); + db.queued.add(() { + try { + completer.complete(_run(db, args, action)); + } catch (e, st) { + completer.completeError(e, st); + } + }); + db.cancels.add(() => completer.completeError( + _wrap(StateError('Database has been closed'), db, args))); + return completer.future; + } + + /// Runs [action], turning a failure into sqflite's exception, and marks + /// the transaction closed when SQLite rolled it back. + Object? _run( + _Database db, + Map args, + Object? Function(_Database db) action, + ) { + try { + return action(db); + } catch (e) { + final SqfliteDatabaseException wrapped = _wrap(e, db, args); + if (db.currentTransactionId != null && db.connection.autocommit) { + db.currentTransactionId = null; + wrapped.transactionClosed = true; + scheduleMicrotask(db.runQueued); + } + throw wrapped; + } + } + + Object? _guard(Object? Function() action, + {required String? sql, required List? arguments}) { + try { + return action(); + } catch (e) { + throw _wrap(e, null, { + if (sql != null) 'sql': sql, + if (arguments != null) 'arguments': arguments, + }); + } + } + + SqfliteDatabaseException _wrap( + Object e, _Database? db, Map args) { + if (e is SqfliteDatabaseException) { + return e; + } + final Object? sql = args['sql']; + final Object? arguments = args['arguments']; + final Map details = { + if (db != null) 'database': db.toDebugMap(), + if (sql != null) 'sql': sql, + if (arguments != null) 'arguments': arguments, + }; + return SqfliteDatabaseException( + '$e', + details.isEmpty ? null : details, + resultCode: e is SqliteFailure ? e.extendedCode : null, + ); + } + + static List _arguments(Map args) => + (args['arguments'] as List?) ?? const []; + + void _executeSql(_Database db, String sql, List arguments) { + if (sql.startsWith(_sqflitePragmaPrefix)) { + return; + } + if (arguments.isEmpty) { + db.connection.executeAll(sql); + } else { + db.connection.execute(sql, arguments); + } + } + + void _checkWritable(_Database db) { + if (db.readOnly) { + throw SqfliteDatabaseException('Database readonly', null); + } + } + + Object? _execute(_Database db, Map args) { + final bool? inTransactionChange = args['inTransaction'] as bool?; + final bool entering = inTransactionChange == true && + args.containsKey('transactionId') && + args['transactionId'] == null; + if (entering) { + db.currentTransactionId = ++db._lastTransactionId; + } + try { + _executeSql(db, args['sql']! as String, _arguments(args)); + } catch (_) { + if (entering) { + db.currentTransactionId = null; + } + rethrow; + } + if (entering) { + return {'transactionId': db.currentTransactionId}; + } + if (inTransactionChange == false) { + db.currentTransactionId = null; + } + return null; + } + + /// The id of the inserted row, or null when nothing was inserted. + int? _lastInsertId(_Database db) => + db.connection.changes == 0 ? null : db.connection.lastInsertRowId; + + Object? _insert(_Database db, Map args) { + _checkWritable(db); + _executeSql(db, args['sql']! as String, _arguments(args)); + return _lastInsertId(db); + } + + Object? _update(_Database db, Map args) { + _checkWritable(db); + _executeSql(db, args['sql']! as String, _arguments(args)); + return db.connection.changes; + } + + static Map _pack( + List columns, List> rows) => + {'columns': columns, 'rows': rows}; + + Object? _query(_Database db, Map args) { + final String sql = args['sql']! as String; + final List arguments = _arguments(args); + final Object? pageSize = args['cursorPageSize']; + if (pageSize is! int) { + final ({List columns, List> rows}) result = + db.connection.select(sql, arguments); + return _pack(result.columns, result.rows); + } + final SqliteStatement statement = db.connection.prepare(sql, arguments); + final _Cursor cursor = _Cursor(++db._lastCursorId, statement, pageSize); + db.cursors[cursor.id] = cursor; + return _page(db, cursor); + } + + Map _page(_Database db, _Cursor cursor) { + try { + final List> rows = >[]; + bool atEnd = false; + while (rows.length < cursor.pageSize) { + if (!cursor.statement.step()) { + atEnd = true; + break; + } + rows.add(cursor.statement.row()); + } + final Map page = _pack(cursor.statement.columns, rows); + if (atEnd) { + db.closeCursor(cursor.id); + } else { + page['cursorId'] = cursor.id; + } + return page; + } catch (_) { + db.closeCursor(cursor.id); + rethrow; + } + } + + Object? _cursorNext(_Database db, Map args) { + final int cursorId = args['cursorId']! as int; + if (args['cancel'] == true) { + db.closeCursor(cursorId); + return null; + } + final _Cursor? cursor = db.cursors[cursorId]; + if (cursor == null) { + throw StateError('Cursor $cursorId not found'); + } + return _page(db, cursor); + } + + Object? _batch(_Database db, Map args) { + final bool noResult = args['noResult'] == true; + final bool continueOnError = args['continueOnError'] == true; + final List>? results = + noResult ? null : >[]; + for (final Object? item in args['operations']! as List) { + final Map operation = item! as Map; + final String method = operation['method']! as String; + final String? sql = operation['sql'] as String?; + final List arguments = _arguments(operation); + try { + final Object? result = switch (method) { + 'insert' => () { + _checkWritable(db); + _executeSql(db, sql!, arguments); + return _lastInsertId(db); + }(), + 'update' => () { + _checkWritable(db); + _executeSql(db, sql!, arguments); + return db.connection.changes; + }(), + 'execute' => () { + _executeSql(db, sql!, arguments); + return null; + }(), + 'query' => () { + final ({List columns, List> rows}) r = + db.connection.select(sql!, arguments); + return _pack(r.columns, r.rows); + }(), + _ => throw UnsupportedError('batch operation $method not supported'), + }; + results?.add({'result': result}); + } catch (e) { + final SqfliteDatabaseException wrapped = _wrap(e, db, operation); + if (!continueOnError) { + throw wrapped; + } + results?.add({ + 'error': { + 'code': 'sqlite_error', + 'message': wrapped.message, + if (wrapped.getResultCode() != null) + 'resultCode': wrapped.getResultCode(), + 'data': { + 'sql': sql, + if (arguments.isNotEmpty) 'arguments': arguments, + }, + }, + }); + if (db.currentTransactionId != null && db.connection.autocommit) { + // SQLite rolled the transaction back: nothing after this counts. + throw wrapped..transactionClosed = true; + } + } + } + return results; + } +} diff --git a/packages/sqflite_watchos/lib/src/sqlite3.dart b/packages/sqflite_watchos/lib/src/sqlite3.dart new file mode 100644 index 0000000..beea6ff --- /dev/null +++ b/packages/sqflite_watchos/lib/src/sqlite3.dart @@ -0,0 +1,516 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// The part of SQLite's C API this package uses, bound over dart:ffi. +// +// On the watch these resolve from the process: watchOS ships libsqlite3 and +// the CLI links it. Host tests pass the macOS system library instead, so the +// same code runs real SQL there. + +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +/// An open `sqlite3*` connection. +final class Sqlite3 extends Opaque {} + +/// A prepared `sqlite3_stmt*`. +final class Sqlite3Statement extends Opaque {} + +/// Result codes and flags from `sqlite3.h`. +abstract final class SqliteCode { + /// `SQLITE_OK`. + static const int ok = 0; + + /// `SQLITE_ROW`. + static const int row = 100; + + /// `SQLITE_DONE`. + static const int done = 101; + + /// `SQLITE_OPEN_READONLY`. + static const int openReadOnly = 0x00000001; + + /// `SQLITE_OPEN_READWRITE`. + static const int openReadWrite = 0x00000002; + + /// `SQLITE_OPEN_CREATE`. + static const int openCreate = 0x00000004; + + /// `SQLITE_OPEN_FULLMUTEX`. + static const int openFullMutex = 0x00010000; + + /// `SQLITE_INTEGER`. + static const int typeInteger = 1; + + /// `SQLITE_FLOAT`. + static const int typeFloat = 2; + + /// `SQLITE_TEXT`. + static const int typeText = 3; + + /// `SQLITE_BLOB`. + static const int typeBlob = 4; +} + +typedef _Destructor = Pointer)>>; + +/// `SQLITE_TRANSIENT`: SQLite copies a bound value before the call returns, +/// so the Dart-side buffer can go right after. +final _Destructor _transient = _Destructor.fromAddress(-1); + +/// A failed SQLite call: its extended result code and `sqlite3_errmsg`. +class SqliteFailure implements Exception { + /// A failure with these values. + const SqliteFailure(this.extendedCode, this.message); + + /// The extended result code, for example 2067 for a UNIQUE constraint. + final int extendedCode; + + /// The message SQLite gave. + final String message; + + @override + String toString() => '$message (code $extendedCode)'; +} + +/// The SQLite C functions, resolved from one library. +class Sqlite3Library { + /// Binds the functions in [library]. + Sqlite3Library(DynamicLibrary library) + : _openV2 = library.lookupFunction< + Int32 Function( + Pointer, Pointer>, Int32, Pointer), + int Function(Pointer, Pointer>, int, + Pointer)>('sqlite3_open_v2'), + _closeV2 = library.lookupFunction), + int Function(Pointer)>('sqlite3_close_v2'), + _extendedResultCodes = library.lookupFunction< + Int32 Function(Pointer, Int32), + int Function( + Pointer, int)>('sqlite3_extended_result_codes'), + _busyTimeout = library.lookupFunction< + Int32 Function(Pointer, Int32), + int Function(Pointer, int)>('sqlite3_busy_timeout'), + _prepareV2 = library.lookupFunction< + Int32 Function(Pointer, Pointer, Int32, + Pointer>, Pointer>), + int Function( + Pointer, + Pointer, + int, + Pointer>, + Pointer>)>('sqlite3_prepare_v2'), + _step = library.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('sqlite3_step'), + _finalize = library.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('sqlite3_finalize'), + _bindParameterCount = library.lookupFunction< + Int32 Function(Pointer), + int Function( + Pointer)>('sqlite3_bind_parameter_count'), + _bindNull = library.lookupFunction< + Int32 Function(Pointer, Int32), + int Function(Pointer, int)>('sqlite3_bind_null'), + _bindInt64 = library.lookupFunction< + Int32 Function(Pointer, Int32, Int64), + int Function( + Pointer, int, int)>('sqlite3_bind_int64'), + _bindDouble = library.lookupFunction< + Int32 Function(Pointer, Int32, Double), + int Function( + Pointer, int, double)>('sqlite3_bind_double'), + _bindText = library.lookupFunction< + Int32 Function(Pointer, Int32, Pointer, + Int32, _Destructor), + int Function(Pointer, int, Pointer, int, + _Destructor)>('sqlite3_bind_text'), + _bindBlob = library.lookupFunction< + Int32 Function(Pointer, Int32, Pointer, + Int32, _Destructor), + int Function(Pointer, int, Pointer, int, + _Destructor)>('sqlite3_bind_blob'), + _bindZeroBlob = library.lookupFunction< + Int32 Function(Pointer, Int32, Int32), + int Function( + Pointer, int, int)>('sqlite3_bind_zeroblob'), + _columnCount = library.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('sqlite3_column_count'), + _columnName = library.lookupFunction< + Pointer Function(Pointer, Int32), + Pointer Function( + Pointer, int)>('sqlite3_column_name'), + _columnType = library.lookupFunction< + Int32 Function(Pointer, Int32), + int Function( + Pointer, int)>('sqlite3_column_type'), + _columnInt64 = library.lookupFunction< + Int64 Function(Pointer, Int32), + int Function( + Pointer, int)>('sqlite3_column_int64'), + _columnDouble = library.lookupFunction< + Double Function(Pointer, Int32), + double Function( + Pointer, int)>('sqlite3_column_double'), + _columnText = library.lookupFunction< + Pointer Function(Pointer, Int32), + Pointer Function( + Pointer, int)>('sqlite3_column_text'), + _columnBlob = library.lookupFunction< + Pointer Function(Pointer, Int32), + Pointer Function( + Pointer, int)>('sqlite3_column_blob'), + _columnBytes = library.lookupFunction< + Int32 Function(Pointer, Int32), + int Function( + Pointer, int)>('sqlite3_column_bytes'), + _errmsg = library.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>('sqlite3_errmsg'), + _errstr = library.lookupFunction Function(Int32), + Pointer Function(int)>('sqlite3_errstr'), + _extendedErrcode = library.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('sqlite3_extended_errcode'), + _changes = library.lookupFunction), + int Function(Pointer)>('sqlite3_changes'), + _lastInsertRowId = library.lookupFunction< + Int64 Function(Pointer), + int Function(Pointer)>('sqlite3_last_insert_rowid'), + _getAutocommit = library.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('sqlite3_get_autocommit'), + _libVersion = library.lookupFunction Function(), + Pointer Function()>('sqlite3_libversion'); + + final int Function( + Pointer, Pointer>, int, Pointer) _openV2; + final int Function(Pointer) _closeV2; + final int Function(Pointer, int) _extendedResultCodes; + final int Function(Pointer, int) _busyTimeout; + final int Function(Pointer, Pointer, int, + Pointer>, Pointer>) _prepareV2; + final int Function(Pointer) _step; + final int Function(Pointer) _finalize; + final int Function(Pointer) _bindParameterCount; + final int Function(Pointer, int) _bindNull; + final int Function(Pointer, int, int) _bindInt64; + final int Function(Pointer, int, double) _bindDouble; + final int Function( + Pointer, int, Pointer, int, _Destructor) + _bindText; + final int Function( + Pointer, int, Pointer, int, _Destructor) + _bindBlob; + final int Function(Pointer, int, int) _bindZeroBlob; + final int Function(Pointer) _columnCount; + final Pointer Function(Pointer, int) _columnName; + final int Function(Pointer, int) _columnType; + final int Function(Pointer, int) _columnInt64; + final double Function(Pointer, int) _columnDouble; + final Pointer Function(Pointer, int) _columnText; + final Pointer Function(Pointer, int) _columnBlob; + final int Function(Pointer, int) _columnBytes; + final Pointer Function(Pointer) _errmsg; + final Pointer Function(int) _errstr; + final int Function(Pointer) _extendedErrcode; + final int Function(Pointer) _changes; + final int Function(Pointer) _lastInsertRowId; + final int Function(Pointer) _getAutocommit; + final Pointer Function() _libVersion; + + /// How long a statement waits for another connection's lock before it + /// fails with `SQLITE_BUSY`: FMDB's default, which sqflite keeps on iOS. + static const int busyTimeoutMilliseconds = 2000; + + /// `sqlite3_libversion()`. + String get version => _libVersion().toDartString(); + + /// Opens [path] with [flags]. Throws a [SqliteFailure] when SQLite cannot. + SqliteConnection open(String path, int flags) => using((Arena arena) { + final Pointer> out = arena>(); + final int rc = + _openV2(path.toNativeUtf8(allocator: arena), out, flags, nullptr); + final Pointer db = out.value; + if (rc != SqliteCode.ok) { + final SqliteFailure failure = db == nullptr + ? SqliteFailure(rc, _errstr(rc).toDartString()) + : SqliteFailure(_extendedErrcode(db), _errmsg(db).toDartString()); + if (db != nullptr) { + _closeV2(db); + } + throw failure; + } + _extendedResultCodes(db, 1); + _busyTimeout(db, busyTimeoutMilliseconds); + return SqliteConnection._(this, db); + }); +} + +/// One open connection, and the statements run on it. +class SqliteConnection { + SqliteConnection._(this._lib, this._db); + + final Sqlite3Library _lib; + final Pointer _db; + bool _closed = false; + + /// Whether [close] has run. + bool get isClosed => _closed; + + /// `sqlite3_changes`: rows changed by the last statement. + int get changes => _lib._changes(_db); + + /// `sqlite3_last_insert_rowid`. + int get lastInsertRowId => _lib._lastInsertRowId(_db); + + /// Whether no transaction is open (`sqlite3_get_autocommit`). + bool get autocommit => _lib._getAutocommit(_db) != 0; + + SqliteFailure _failure() => SqliteFailure( + _lib._extendedErrcode(_db), _lib._errmsg(_db).toDartString()); + + /// Closes the connection. Statements still open are finalized first. + void close() { + if (_closed) { + return; + } + _closed = true; + _lib._closeV2(_db); + } + + /// Runs every statement in [sql], as `sqlite3_exec` would. + void executeAll(String sql) { + using((Arena arena) { + final Uint8List bytes = utf8.encode(sql); + final Pointer buffer = arena(bytes.length + 1); + buffer.asTypedList(bytes.length).setAll(0, bytes); + buffer[bytes.length] = 0; + final Pointer> out = + arena>(); + final Pointer> tail = arena>(); + Pointer cursor = buffer; + final int end = buffer.address + bytes.length; + while (cursor.address < end) { + final int remaining = end - cursor.address; + if (_lib._prepareV2(_db, cursor, remaining, out, tail) != + SqliteCode.ok) { + throw _failure(); + } + final Pointer statement = out.value; + final Pointer next = tail.value; + if (statement != nullptr) { + try { + int rc; + do { + rc = _lib._step(statement); + } while (rc == SqliteCode.row); + if (rc != SqliteCode.done) { + throw _failure(); + } + } finally { + _lib._finalize(statement); + } + } + if (next == nullptr || next.address <= cursor.address) { + break; + } + cursor = next; + } + }); + } + + /// Prepares the first statement of [sql] and binds [arguments]. + SqliteStatement prepare(String sql, List arguments) => + using((Arena arena) { + final Uint8List bytes = utf8.encode(sql); + final Pointer buffer = arena(bytes.length + 1); + buffer.asTypedList(bytes.length).setAll(0, bytes); + buffer[bytes.length] = 0; + final Pointer> out = + arena>(); + if (_lib._prepareV2(_db, buffer, bytes.length, out, nullptr) != + SqliteCode.ok) { + throw _failure(); + } + final SqliteStatement statement = SqliteStatement._(this, out.value); + try { + statement._bind(arguments); + } catch (_) { + statement.close(); + rethrow; + } + return statement; + }); + + /// Runs [sql] with [arguments], discarding any rows. + void execute(String sql, List arguments) { + final SqliteStatement statement = prepare(sql, arguments); + try { + while (statement.step()) {} + } finally { + statement.close(); + } + } + + /// Runs [sql] with [arguments] and returns its columns and every row. + ({List columns, List> rows}) select( + String sql, List arguments) { + final SqliteStatement statement = prepare(sql, arguments); + try { + final List> rows = >[]; + while (statement.step()) { + rows.add(statement.row()); + } + return (columns: statement.columns, rows: rows); + } finally { + statement.close(); + } + } +} + +/// A prepared statement, stepped row by row. +class SqliteStatement { + SqliteStatement._(this._connection, this._statement); + + final SqliteConnection _connection; + final Pointer _statement; + bool _closed = false; + + Sqlite3Library get _lib => _connection._lib; + + void _check(int rc) { + if (rc != SqliteCode.ok) { + throw _connection._failure(); + } + } + + void _bind(List arguments) { + if (_statement == nullptr) { + // Only whitespace or a comment: nothing to bind, nothing to run. + return; + } + final int count = _lib._bindParameterCount(_statement); + if (arguments.length != count) { + throw SqliteFailure( + 25, // SQLITE_RANGE + 'Expected $count arguments, got ${arguments.length}'); + } + for (int i = 0; i < arguments.length; i++) { + final int index = i + 1; + final Object? value = arguments[i]; + switch (value) { + case null: + _check(_lib._bindNull(_statement, index)); + case int(): + _check(_lib._bindInt64(_statement, index, value)); + case bool(): + // As sqflite_darwin and sqflite_common_ffi bind it. + _check(_lib._bindInt64(_statement, index, value ? 1 : 0)); + case double(): + _check(_lib._bindDouble(_statement, index, value)); + case BigInt(): + if (!value.isValidInt) { + throw ArgumentError.value( + value, 'arguments', 'does not fit in a 64-bit integer'); + } + _check(_lib._bindInt64(_statement, index, value.toInt())); + case String(): + using((Arena arena) { + final Uint8List bytes = utf8.encode(value); + final Pointer p = + arena(bytes.isEmpty ? 1 : bytes.length); + p.asTypedList(bytes.length).setAll(0, bytes); + _check( + _lib._bindText(_statement, index, p, bytes.length, _transient)); + }); + case List(): + // A Uint8List, or a plain List as sqflite_darwin and + // sqflite_common_ffi take one: a blob of one byte per value. + if (value.isEmpty) { + _check(_lib._bindZeroBlob(_statement, index, 0)); + } else { + using((Arena arena) { + final Pointer p = arena(value.length); + p.asTypedList(value.length).setAll(0, value); + _check(_lib._bindBlob( + _statement, index, p, value.length, _transient)); + }); + } + default: + throw ArgumentError( + "Invalid sql argument type '${value.runtimeType}': $value"); + } + } + } + + /// The result column names. + List get columns => [ + if (_statement != nullptr) + for (int i = 0; i < _lib._columnCount(_statement); i++) + _lib._columnName(_statement, i).toDartString(), + ]; + + /// Steps once: true when a row is ready, false when the statement is done. + bool step() { + if (_statement == nullptr) { + return false; + } + final int rc = _lib._step(_statement); + if (rc == SqliteCode.row) { + return true; + } + if (rc == SqliteCode.done) { + return false; + } + throw _connection._failure(); + } + + /// The current row's values: int, double, String, Uint8List or null. + List row() { + final int count = _lib._columnCount(_statement); + return [ + for (int i = 0; i < count; i++) _value(i), + ]; + } + + Object? _value(int i) { + switch (_lib._columnType(_statement, i)) { + case SqliteCode.typeInteger: + return _lib._columnInt64(_statement, i); + case SqliteCode.typeFloat: + return _lib._columnDouble(_statement, i); + case SqliteCode.typeText: + final Pointer text = _lib._columnText(_statement, i); + final int length = _lib._columnBytes(_statement, i); + return text == nullptr + ? '' + : utf8.decode(text.asTypedList(length), allowMalformed: true); + case SqliteCode.typeBlob: + final Pointer blob = _lib._columnBlob(_statement, i); + final int length = _lib._columnBytes(_statement, i); + return blob == nullptr + ? Uint8List(0) + : Uint8List.fromList(blob.asTypedList(length)); + default: + return null; + } + } + + /// Finalizes the statement. + void close() { + if (_closed) { + return; + } + _closed = true; + if (_statement != nullptr) { + _lib._finalize(_statement); + } + } +} diff --git a/packages/sqflite_watchos/pubspec.yaml b/packages/sqflite_watchos/pubspec.yaml new file mode 100644 index 0000000..dcde999 --- /dev/null +++ b/packages/sqflite_watchos/pubspec.yaml @@ -0,0 +1,58 @@ +name: sqflite_watchos +description: "watchOS implementation of the sqflite plugin: SQLite databases on Apple Watch, on the system SQLite via dart:ffi." +version: 0.0.1 +homepage: https://flutterwatch.dev +repository: https://github.com/flutterwatch/plugins/tree/main/packages/sqflite_watchos +issue_tracker: https://github.com/flutterwatch/plugins/issues +topics: + - watchos + - ffi + - sqlite + - database +# Generated by `flutter-watchos plugin port`. See PORTING_REPORT.md. +# License holder: The FlutterWatch Authors + +environment: + # Records and switch expressions, which src/ uses, need 3.0; 3.1 matches + # the other packages here. + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + ffi: ^2.1.0 + flutter: + sdk: flutter + path: ^1.8.0 + # buildDatabaseFactory and SqfliteDatabaseException come from src/, which + # sqflite_common_ffi imports the same way. src/ is not public API, so the + # constraint stops below the next minor. 2.5.14 fixes a hang when a cursor + # is closed inside a transaction. + sqflite_common: ">=2.5.14 <2.6.0" + sqflite_platform_interface: ^2.4.1 + +dev_dependencies: + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter + +flutter: + plugin: + implements: sqflite + platforms: + watchos: + # FFI plugin: the CLI compiles watchos/Classes/*.m into a + # static archive force-loaded into the watch binary, and links + # the system libsqlite3 that Package.swift names; Dart resolves + # every symbol via DynamicLibrary.process(). + # Method-channel plugins are not supported on watchOS, + # so this is the supported plugin model. + ffiPlugin: true + dartPluginClass: SqfliteWatchos + # This package's own C exports. They survive the link because the + # CLI force-loads the plugin archive, each export is marked used + # with default visibility, and the CLI keeps global symbols + # through the App Store strip. SQLite's own functions come from + # the system libsqlite3. + ffiSymbols: + - sqflite_watchos_databases_path + - sqflite_watchos_sqlite_version diff --git a/packages/sqflite_watchos/test/sqflite_watchos_test.dart b/packages/sqflite_watchos/test/sqflite_watchos_test.dart new file mode 100644 index 0000000..a3b200e --- /dev/null +++ b/packages/sqflite_watchos/test/sqflite_watchos_test.dart @@ -0,0 +1,711 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Host-side tests that run real SQL. The handler binds SQLite's C API, which +// macOS and CI's Linux runner ship too, so these point it at the host's +// system libsqlite3 and drive it through sqflite_common's own Database API, +// the way an app does. The watch resolves the same functions from its +// process instead. + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common/sqflite.dart'; +// ignore: implementation_imports +import 'package:sqflite_common/src/exception.dart' + show SqfliteDatabaseException; +// ignore: implementation_imports +import 'package:sqflite_common/src/mixin/factory.dart' + show buildDatabaseFactory; +import 'package:sqflite_platform_interface/sqflite_platform_interface.dart'; +import 'package:sqflite_watchos/sqflite_watchos.dart'; +import 'package:sqflite_watchos/src/handler.dart'; +import 'package:sqflite_watchos/src/sqlite3.dart'; + +/// The host's system SQLite: `/usr/lib` on macOS, the shared library's +/// soname on Linux. +DynamicLibrary hostSqlite() => Platform.isMacOS + ? DynamicLibrary.open('/usr/lib/libsqlite3.dylib') + : DynamicLibrary.open('libsqlite3.so.0'); + +void main() { + late Directory dir; + late DatabaseFactory factory; + + setUpAll(() { + dir = Directory.systemTemp.createTempSync('sqflite_watchos_test'); + SqfliteWatchos.libraryOverride = hostSqlite(); + SqfliteWatchos.databasesPathOverride = dir.path; + SqfliteWatchos.resetForTesting(); + factory = SqfliteWatchos.databaseFactoryWatchos; + }); + + tearDownAll(() => dir.deleteSync(recursive: true)); + + Future openFresh(String name, + {OpenDatabaseOptions? options}) async { + await factory.deleteDatabase(name); + return factory.openDatabase(name, options: options); + } + + test('registerWith makes the watchOS factory the default', () { + databaseFactoryOrNull = null; + SqfliteWatchos.registerWith(); + expect(identical(databaseFactoryOrNull, factory), isTrue); + }); + + test('registerWith keeps a factory the app already set', () { + final DatabaseFactory other = buildDatabaseFactory( + invokeMethod: (String method, [Object? arguments]) async => null); + databaseFactoryOrNull = other; + SqfliteWatchos.registerWith(); + expect(identical(databaseFactoryOrNull, other), isTrue); + expect(identical(databaseFactoryOrNull, factory), isFalse); + databaseFactoryOrNull = null; + SqfliteWatchos.registerWith(); + }); + + test('the implementation is a SqflitePlatform', () { + expect(SqfliteWatchos(), isA()); + }); + + test('getDatabasesPath is where relative paths go', () async { + expect(await factory.getDatabasesPath(), dir.path); + final Database db = await openFresh('relative.db'); + expect(db.path, p.join(dir.path, 'relative.db')); + await db.close(); + expect(File(p.join(dir.path, 'relative.db')).existsSync(), isTrue); + }); + + test('a missing parent directory is created', () async { + final Database db = await openFresh(p.join('sub', 'dir', 'nested.db')); + await db.close(); + expect( + File(p.join(dir.path, 'sub', 'dir', 'nested.db')).existsSync(), isTrue); + }); + + test('every SQLite type comes back as it went in', () async { + final Database db = await openFresh('types.db'); + await db.execute('CREATE TABLE t (i INTEGER, r REAL, s TEXT, b BLOB, n)'); + final Uint8List blob = Uint8List.fromList([0, 1, 2, 254, 255]); + await db.insert('t', { + 'i': 4611686018427387904, // 2^62: beyond a double's exact integers + 'r': 3.25, + 's': 'İstanbul ☕ 日本', + 'b': blob, + 'n': null, + }); + await db.insert('t', { + 'i': -1, + 'r': -0.5, + 's': '', + 'b': Uint8List(0), + 'n': null, + }); + final List> rows = await db.query('t'); + expect(rows[0]['i'], 4611686018427387904); + expect(rows[0]['r'], 3.25); + expect(rows[0]['s'], 'İstanbul ☕ 日本'); + expect(rows[0]['b'], blob); + expect(rows[0]['n'], isNull); + expect(rows[1]['s'], ''); + expect(rows[1]['b'], Uint8List(0)); + await db.close(); + }); + + test('bools and plain int lists bind as on iOS: 1 or 0, and a blob', + () async { + final Database db = await factory.openDatabase(inMemoryDatabasePath); + await db.execute('CREATE TABLE t (flag INTEGER, data BLOB)'); + await db.insert('t', { + 'flag': true, + 'data': [1, 2, 255], + }); + await db.insert('t', {'flag': false, 'data': []}); + final List> rows = + await db.rawQuery('SELECT flag, data, typeof(data) AS type FROM t'); + expect(rows[0]['flag'], 1); + expect(rows[0]['data'], Uint8List.fromList([1, 2, 255])); + expect(rows[0]['type'], 'blob'); + expect(rows[1]['flag'], 0); + expect(rows[1]['data'], Uint8List(0)); + expect(rows[1]['type'], 'blob'); + expect( + await db + .rawQuery('SELECT count(*) AS n FROM t WHERE data = ?', [ + [1, 2, 255] + ]), + >[ + {'n': 1}, + ]); + await db.close(); + }); + + test('a locked database is waited for, as FMDB waits on iOS', () async { + final OpenDatabaseOptions separate = + OpenDatabaseOptions(singleInstance: false); + final Database first = await openFresh('busy.db', options: separate); + await first.execute('CREATE TABLE t (x)'); + final Database second = + await factory.openDatabase('busy.db', options: separate); + expect(await second.rawQuery('PRAGMA busy_timeout'), >[ + {'timeout': 2000}, + ]); + await first.execute('BEGIN IMMEDIATE'); + final Stopwatch waited = Stopwatch()..start(); + await expectLater( + second.insert('t', {'x': 1}), + throwsA(isA().having( + (DatabaseException e) => e.getResultCode(), 'SQLITE_BUSY', 5)), + ); + // Apple's SQLite, which the watch runs, waits out the timeout first. The + // libsqlite3 of CI's Ubuntu runner failed the same insert after 39 ms, + // without waiting, so the wait is only checked on macOS; the timeout + // itself is checked above on both. + if (Platform.isMacOS) { + expect(waited.elapsedMilliseconds, greaterThanOrEqualTo(1500)); + } + await first.execute('ROLLBACK'); + await second.close(); + await first.close(); + }); + + test('insert returns the row id, and 0 when nothing was inserted', () async { + final Database db = await openFresh('insert.db'); + await db + .execute('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT UNIQUE)'); + expect(await db.insert('t', {'name': 'a'}), 1); + expect(await db.insert('t', {'name': 'b'}), 2); + expect( + await db.insert('t', {'name': 'a'}, + conflictAlgorithm: ConflictAlgorithm.ignore), + 0); + await db.close(); + }); + + test('update and delete return the changed row count', () async { + final Database db = await openFresh('update.db'); + await db.execute('CREATE TABLE t (x INTEGER)'); + for (int i = 0; i < 5; i++) { + await db.insert('t', {'x': i}); + } + expect(await db.update('t', {'x': 10}, where: 'x > 2'), 2); + expect(await db.delete('t', where: 'x = 10'), 2); + expect(await db.rawUpdate('UPDATE t SET x = x + 1'), 3); + await db.close(); + }); + + test('several statements run in one execute', () async { + final Database db = await openFresh('multi.db'); + await db.execute('CREATE TABLE a (x); CREATE TABLE b (y); ' + 'INSERT INTO a VALUES (1); INSERT INTO b VALUES (2);'); + expect(await db.rawQuery('SELECT x FROM a'), >[ + {'x': 1}, + ]); + expect(await db.rawQuery('SELECT y FROM b'), >[ + {'y': 2}, + ]); + await db.close(); + }); + + test( + 'numbered parameters and duplicated columns read as on other ' + 'platforms', () async { + final Database db = await factory.openDatabase(inMemoryDatabasePath); + expect( + await db.rawQuery('SELECT ?1 + ?2', [3, 4]), + >[ + {'?1 + ?2': 7}, + ]); + await db.execute('CREATE TABLE t (col1 INTEGER, col2 INTEGER)'); + await db.insert('t', {'col1': 1, 'col2': 2}); + expect( + await db + .rawQuery('SELECT t.col1, col1, t.col2, col2 AS col1 FROM t AS t'), + >[ + {'col1': 2, 'col2': 2}, + ]); + await db.close(); + }); + + test('versions run onCreate and onUpgrade', () async { + final List calls = []; + Database db = await openFresh( + 'version.db', + options: OpenDatabaseOptions( + version: 1, + onCreate: (Database db, int version) async { + calls.add('create $version'); + await db.execute('CREATE TABLE t (x)'); + }, + ), + ); + expect(await db.getVersion(), 1); + await db.close(); + db = await factory.openDatabase( + 'version.db', + options: OpenDatabaseOptions( + version: 2, + onUpgrade: (Database db, int from, int to) async { + calls.add('upgrade $from $to'); + }, + ), + ); + expect(await db.getVersion(), 2); + expect(calls, ['create 1', 'upgrade 1 2']); + await db.close(); + }); + + group('errors', () { + test('a unique constraint names itself and carries its code', () async { + final Database db = await openFresh('unique.db'); + await db.execute('CREATE TABLE t (name TEXT UNIQUE)'); + await db.insert('t', {'name': 'a'}); + try { + await db.insert('t', {'name': 'a'}); + fail('the second insert should fail'); + } on DatabaseException catch (e) { + expect(e.isUniqueConstraintError('t.name'), isTrue); + expect(e.getResultCode(), 2067); // SQLITE_CONSTRAINT_UNIQUE + } + await db.close(); + }); + + test('a syntax error and a missing table are recognised', () async { + final Database db = await factory.openDatabase(inMemoryDatabasePath); + await expectLater( + db.execute('CREAT TABLE t (x)'), + throwsA(isA().having( + (DatabaseException e) => e.isSyntaxError(), 'syntax', true)), + ); + await expectLater( + db.query('missing'), + throwsA(isA().having( + (DatabaseException e) => e.isNoSuchTableError('missing'), + 'no such table', + true)), + ); + await db.close(); + }); + + test('opening a missing database read-only fails to open', () async { + await factory.deleteDatabase('absent.db'); + await expectLater( + factory.openDatabase('absent.db', + options: OpenDatabaseOptions(readOnly: true)), + throwsA(isA().having( + (DatabaseException e) => e.isOpenFailedError(), + 'open failed', + true)), + ); + }); + + test('writing to a read-only database fails as read-only', () async { + final Database rw = await openFresh('readonly.db'); + await rw.execute('CREATE TABLE t (x)'); + await rw.close(); + final Database ro = await factory.openDatabase('readonly.db', + options: OpenDatabaseOptions(readOnly: true)); + await expectLater( + ro.insert('t', {'x': 1}), + throwsA(isA().having( + (DatabaseException e) => e.isReadOnlyError(), 'readonly', true)), + ); + expect(await ro.query('t'), isEmpty); + await ro.close(); + }); + + test('a file that is not a database opens, then fails to read', () async { + final File file = File(p.join(dir.path, 'not_a_database.db')) + ..writeAsStringSync('test'); + final Database db = await factory.openDatabase(file.path, + options: OpenDatabaseOptions(readOnly: true)); + await expectLater(db.getVersion(), throwsA(isA())); + await db.close(); + expect(file.readAsStringSync(), 'test'); + }); + + test('a closed database says so', () async { + final Database db = await openFresh('closed.db'); + await db.close(); + // sqflite_common refuses before the call reaches the handler, and + // synchronously. + await expectLater( + () => db.query('sqlite_master'), + throwsA(isA().having( + (DatabaseException e) => e.isDatabaseClosedError(), + 'closed', + true)), + ); + }); + }); + + group('transactions', () { + test('a transaction commits', () async { + final Database db = await openFresh('commit.db'); + await db.execute('CREATE TABLE t (x)'); + await db.transaction((Transaction txn) async { + await txn.insert('t', {'x': 1}); + await txn.insert('t', {'x': 2}); + }); + expect(await db.query('t'), hasLength(2)); + await db.close(); + }); + + test('a failing transaction rolls back', () async { + final Database db = await openFresh('rollback.db'); + await db.execute('CREATE TABLE t (x UNIQUE)'); + await expectLater( + db.transaction((Transaction txn) async { + await txn.insert('t', {'x': 1}); + await txn.insert('t', {'x': 1}); + }), + throwsA(isA()), + ); + expect(await db.query('t'), isEmpty); + await db.close(); + }); + + test('a call outside a running transaction waits for it', () async { + final Database db = await openFresh('queue.db'); + await db.execute('CREATE TABLE t (x)'); + final Completer inside = Completer(); + final Completer release = Completer(); + final Future txn = db.transaction((Transaction txn) async { + await txn.insert('t', {'x': 1}); + inside.complete(); + await release.future; + await txn.insert('t', {'x': 2}); + }); + await inside.future; + final Future>> outside = db.query('t'); + bool outsideDone = false; + unawaited(outside.then((_) => outsideDone = true)); + await pumpEventQueue(); + expect(outsideDone, isFalse, reason: 'the transaction is still open'); + release.complete(); + await txn; + expect(await outside, hasLength(2), + reason: 'it ran after the transaction committed'); + await db.close(); + }); + + test('closing inside a raw transaction, then reopening, works', () async { + Database db = await openFresh('close_in_txn.db', + options: OpenDatabaseOptions(version: 1)); + await db.execute('BEGIN TRANSACTION'); + await db.close(); + db = await factory.openDatabase('close_in_txn.db', + options: OpenDatabaseOptions(version: 1)); + expect(await db.getVersion(), 1); + await db.close(); + }); + }); + + group('batch', () { + test('results come back in order', () async { + final Database db = await openFresh('batch.db'); + final Batch batch = db.batch() + ..execute('CREATE TABLE t (id INTEGER PRIMARY KEY, x)') + ..insert('t', {'x': 'a'}) + ..insert('t', {'x': 'b'}) + ..update('t', {'x': 'c'}, where: 'id = 1') + ..query('t', columns: ['x']); + final List results = await batch.commit(); + expect(results[1], 1); + expect(results[2], 2); + expect(results[3], 1); + expect(results[4], >[ + {'x': 'c'}, + {'x': 'b'}, + ]); + await db.close(); + }); + + test('continueOnError keeps going and reports the failure', () async { + final Database db = await openFresh('batch_errors.db'); + await db.execute('CREATE TABLE t (x UNIQUE)'); + final Batch batch = db.batch() + ..insert('t', {'x': 1}) + ..insert('t', {'x': 1}) + ..insert('t', {'x': 2}); + final List results = await batch.commit(continueOnError: true); + expect(results[0], 1); + expect(results[1], isA()); + expect( + (results[1]! as DatabaseException).isUniqueConstraintError(), isTrue); + // The failed insert used no row id. + expect(results[2], 2); + expect(await db.query('t'), hasLength(2)); + await db.close(); + }); + + test('noResult returns nothing and still runs', () async { + final Database db = await openFresh('batch_noresult.db'); + final Batch batch = db.batch() + ..execute('CREATE TABLE t (x)') + ..insert('t', {'x': 1}); + expect(await batch.commit(noResult: true), isEmpty); + expect(await db.query('t'), hasLength(1)); + await db.close(); + }); + }); + + group('cursors', () { + late Database db; + + setUp(() async { + db = await factory.openDatabase(inMemoryDatabasePath); + await db.execute('CREATE TABLE t (x INTEGER)'); + final Batch batch = db.batch(); + for (int i = 0; i < 7; i++) { + batch.insert('t', {'x': i}); + } + await batch.commit(noResult: true); + }); + + tearDown(() => db.close()); + + test('a cursor pages through every row', () async { + final QueryCursor cursor = + await db.queryCursor('t', orderBy: 'x', bufferSize: 3); + final List seen = []; + while (await cursor.moveNext()) { + seen.add(cursor.current['x']); + } + expect(seen, [0, 1, 2, 3, 4, 5, 6]); + }); + + test('a cursor closed early releases its statement', () async { + final QueryCursor cursor = + await db.queryCursor('t', orderBy: 'x', bufferSize: 2); + expect(await cursor.moveNext(), isTrue); + await cursor.close(); + // A statement left open would keep the table busy for this drop. + await db.execute('DROP TABLE t'); + }); + }); + + group('files', () { + test('databaseExists and deleteDatabase', () async { + expect(await factory.databaseExists(inMemoryDatabasePath), isFalse); + await factory.deleteDatabase('exists.db'); + expect(await factory.databaseExists('exists.db'), isFalse); + final Database db = await factory.openDatabase('exists.db'); + expect(await factory.databaseExists('exists.db'), isTrue); + await db.close(); + await factory.deleteDatabase('exists.db'); + expect(await factory.databaseExists('exists.db'), isFalse); + }); + + test('deleteDatabase deletes the files SQLite keeps beside it', () async { + final String path = p.join(dir.path, 'sidecars.db'); + final List files = [ + for (final String suffix in ['', '-wal', '-shm', '-journal']) + File('$path$suffix')..writeAsStringSync('x'), + ]; + await factory.deleteDatabase('sidecars.db'); + for (final File file in files) { + expect(file.existsSync(), isFalse, reason: file.path); + } + }); + + test('deleting an open single-instance database closes it first', () async { + Database db = await openFresh('delete_open.db'); + await db.setVersion(1); + await db.execute('BEGIN TRANSACTION'); + await db.setVersion(2); + await factory.deleteDatabase('delete_open.db'); + db = await factory.openDatabase('delete_open.db'); + expect(await db.getVersion(), 0); + await db.close(); + }); + + test('database bytes can be read and written', () async { + final Database db = await openFresh('bytes.db'); + await db.execute('CREATE TABLE t (x)'); + await db.insert('t', {'x': 42}); + await db.close(); + final Uint8List bytes = await factory.readDatabaseBytes('bytes.db'); + expect(bytes.length, greaterThan(0)); + await factory.writeDatabaseBytes('copy.db', bytes); + final Database copy = await factory.openDatabase('copy.db'); + expect(await copy.query('t'), >[ + {'x': 42}, + ]); + await copy.close(); + }); + }); + + test('sqliteVersion reads the library version', () { + expect(SqfliteWatchos.sqliteVersion, matches(RegExp(r'^3\.\d+\.\d+'))); + }); + + group('the method protocol, below sqflite_common', () { + late SqfliteWatchosHandler handler; + + setUp(() { + handler = SqfliteWatchosHandler( + Sqlite3Library(hostSqlite()), + databasesPath: dir.path, + platformVersion: 'watchOS test', + ); + }); + + Future open() async => + ((await handler.handle('openDatabase', { + 'path': inMemoryDatabasePath, + }))! as Map)['id']! as int; + + test('a call from outside an open transaction is queued until it ends', + () async { + final int id = await open(); + await handler.handle('execute', { + 'id': id, + 'sql': 'CREATE TABLE t (x)', + }); + final Object? begun = await handler.handle('execute', { + 'id': id, + 'sql': 'BEGIN IMMEDIATE', + 'inTransaction': true, + 'transactionId': null, + }); + final int txn = + (begun! as Map)['transactionId']! as int; + + bool outsideDone = false; + final Future outside = + handler.handle('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': ['outside'], + }).then((Object? v) { + outsideDone = true; + return v; + }); + await handler.handle('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': ['inside'], + 'transactionId': txn, + }); + await pumpEventQueue(); + expect(outsideDone, isFalse); + + await handler.handle('execute', { + 'id': id, + 'sql': 'COMMIT', + 'inTransaction': false, + 'transactionId': txn, + }); + expect(await outside, 2); + final Object? rows = await handler.handle('query', { + 'id': id, + 'sql': 'SELECT x FROM t', + }); + expect((rows! as Map)['rows'], [ + ['inside'], + ['outside'], + ]); + }); + + test('closing a database cancels what is queued on it', () async { + final int id = await open(); + final Object? begun = await handler.handle('execute', { + 'id': id, + 'sql': 'BEGIN', + 'inTransaction': true, + 'transactionId': null, + }); + expect(begun, isA>()); + final Future queued = handler + .handle('query', {'id': id, 'sql': 'SELECT 1'}); + // Listened to before the close fails it. + final Future failed = + expectLater(queued, throwsA(isA())); + await handler.handle('closeDatabase', {'id': id}); + await failed; + }); + + test('a closed database id answers as closed', () async { + final int id = await open(); + await handler.handle('closeDatabase', {'id': id}); + // Closing twice is fine. + await handler.handle('closeDatabase', {'id': id}); + await expectLater( + handler.handle('query', {'id': id, 'sql': 'SELECT 1'}), + throwsA(isA().having( + (DatabaseException e) => e.isDatabaseClosedError(), + 'closed', + true)), + ); + }); + + test('a failure that rolls the transaction back marks it closed', () async { + final int id = await open(); + await handler.handle('execute', { + 'id': id, + 'sql': 'CREATE TABLE t (x UNIQUE ON CONFLICT ROLLBACK)', + }); + final Object? begun = await handler.handle('execute', { + 'id': id, + 'sql': 'BEGIN', + 'inTransaction': true, + 'transactionId': null, + }); + final int txn = + (begun! as Map)['transactionId']! as int; + Future insert() => handler.handle('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (1)', + 'transactionId': txn, + }); + await insert(); + await expectLater( + insert(), + throwsA(isA().having( + (SqfliteDatabaseException e) => e.transactionClosed, + 'transactionClosed', + true)), + ); + // The database takes calls from outside again at once. + expect( + await handler + .handle('query', {'id': id, 'sql': 'SELECT 1'}), + isNotNull); + }); + + test('an in-memory database is never a single instance', () async { + Future openShared() async => + ((await handler.handle('openDatabase', { + 'path': inMemoryDatabasePath, + 'singleInstance': true, + }))! as Map)['id']! as int; + final int first = await openShared(); + final int second = await openShared(); + expect(second, isNot(first)); + await handler.handle('execute', { + 'id': first, + 'sql': 'CREATE TABLE t (x)', + }); + await expectLater( + handler.handle( + 'query', {'id': second, 'sql': 'SELECT x FROM t'}), + throwsA(isA().having( + (DatabaseException e) => e.isNoSuchTableError('t'), + 'no such table', + true)), + ); + }); + + test('getPlatformVersion and an unknown method', () async { + expect(await handler.handle('getPlatformVersion'), 'watchOS test'); + await expectLater(handler.handle('nope'), throwsA(isA())); + }); + }); +} diff --git a/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.h b/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.h new file mode 100644 index 0000000..b61c578 --- /dev/null +++ b/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.h @@ -0,0 +1,32 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// watchOS implementation of `sqflite`, over dart:ffi. +// +// SQLite itself is the system library: watchOS ships libsqlite3, the CLI links +// it (`.linkedLibrary("sqlite3")` in Package.swift), and Dart calls its C API +// directly through `DynamicLibrary.process()`. This file adds only what +// SQLite does not have: where databases live, and a call into libsqlite3 +// that keeps the library linked into the watch binary. + +#ifndef SQFLITE_WATCHOS_FFI_H +#define SQFLITE_WATCHOS_FFI_H + +// Each exported symbol is marked `used` + default-visibility so it survives +// the linker's `-dead_strip` and lands in the executable's dynamic symbol +// table, where `DynamicLibrary.process()` / dlsym can resolve it. +#define SQFLITE_WATCHOS_EXPORT \ + __attribute__((visibility("default"))) __attribute__((used)) + +// The app's Documents directory, where sqflite keeps databases on iOS too. +// The string is owned by this file and lives as long as the app. +SQFLITE_WATCHOS_EXPORT +const char* sqflite_watchos_databases_path(void); + +// sqlite3_libversion(), for example "3.46.1". Also what keeps libsqlite3 +// linked: the binary references it from here. +SQFLITE_WATCHOS_EXPORT +const char* sqflite_watchos_sqlite_version(void); + +#endif // SQFLITE_WATCHOS_FFI_H diff --git a/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.m b/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.m new file mode 100644 index 0000000..0d84007 --- /dev/null +++ b/packages/sqflite_watchos/watchos/Classes/sqflite_watchos_ffi.m @@ -0,0 +1,34 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// The two native helpers sqflite_watchos needs; see the header. Everything +// else is libsqlite3's own C API, called from Dart. + +#import "sqflite_watchos_ffi.h" + +#import +#import +#import + +const char* sqflite_watchos_databases_path(void) { + static char* path; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSString* directory = + NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, + NSUserDomainMask, YES) + .firstObject; + if (directory == nil) { + directory = NSTemporaryDirectory(); + } + // A copy of its own: fileSystemRepresentation's buffer is only + // autoreleased, whatever keeps the string alive. + path = strdup(directory.fileSystemRepresentation); + }); + return path; +} + +const char* sqflite_watchos_sqlite_version(void) { + return sqlite3_libversion(); +} diff --git a/packages/sqflite_watchos/watchos/Package.swift b/packages/sqflite_watchos/watchos/Package.swift new file mode 100644 index 0000000..57724d1 --- /dev/null +++ b/packages/sqflite_watchos/watchos/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.9 +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// FFI plugin manifest. The flutter-watchos CLI discovers the plugin through +// this file, compiles `Classes/*.m` into a static archive force-loaded into +// the watch binary, and links the frameworks and libraries declared below. +import PackageDescription + +let package = Package( + name: "sqflite_watchos", + platforms: [ + .watchOS("7.0"), + ], + products: [ + .library(name: "sqflite-watchos", targets: ["sqflite_watchos"]), + ], + targets: [ + .target( + name: "sqflite_watchos", + path: "Classes", + publicHeadersPath: ".", + cSettings: [ + .headerSearchPath("."), + ], + linkerSettings: [ + .linkedFramework("Foundation"), + // The system SQLite, whose C API Dart calls directly. + .linkedLibrary("sqlite3"), + ] + ), + ] +) diff --git a/tool/upstream_versions.yaml b/tool/upstream_versions.yaml index 9dde2aa..a186683 100644 --- a/tool/upstream_versions.yaml +++ b/tool/upstream_versions.yaml @@ -26,6 +26,7 @@ upstreams: path_provider: 2.1.6 sensors_plus: 7.1.0 shared_preferences: 2.5.5 + sqflite: 2.4.5 url_launcher: 6.3.2 video_player: 2.14.0