diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d0d7319..04522322 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to central +name: Deploy to Sonatype on: workflow_dispatch @@ -13,40 +13,44 @@ jobs: strategy: matrix: include: - - target: publishIosArm64PublicationToSonatypeRepository + - target: publishAllPublicationsToSonatypeRepository os: macos-latest - - target: publishAndroidReleasePublicationToSonatypeRepository - os: ubuntu-latest - - target: publishKotlinMultiplatformPublicationToSonatypeRepository - os: ubuntu-latest +# FIXME: Our custom gradle plugin does not currently setup platform specific tasks +# - target: publishIosArm64PublicationToSonatypeRepository +# os: macos-latest +# - target: publishAndroidDebugPublicationToSonatypeRepository +# os: ubuntu-latest +# - target: publishAndroidReleasePublicationToSonatypeRepository +# os: ubuntu-latest +# - target: publishKotlinMultiplatformPublicationToSonatypeRepository +# os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 - - uses: actions/cache@v3 - with: - path: | - ~/.konan - key: ${{ runner.os }}-${{ hashFiles('**/.lock') }} - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v5 - with: - gpg_private_key: ${{ secrets.OSSRH_GPG_SECRET_KEY }} - passphrase: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - name: Gradle publish - uses: gradle/gradle-build-action@ce999babab2de1c4b649dc15f0ee67e6246c994f - with: - arguments: | - ${{ matrix.target }} - closeSonatypeStagingRepository - -Psigning.gnupg.passphrase='${{secrets.OSSRH_GPG_SECRET_KEY_PASSWORD}}' - -Psigning.gnupg.keyName='${{secrets.OSSRH_GPG_SECRET_KEY_ID}}' - -PsonatypeUsername='${{secrets.OSSRH_USERNAME}}' - -PsonatypePassword='${{secrets.OSSRH_PASSWORD}}' + - uses: actions/checkout@v4 + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + - uses: actions/cache@v3 + with: + path: | + ~/.konan + key: ${{ runner.os }}-${{ hashFiles('**/.lock') }} + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Gradle publish + run: | + ./gradlew \ + ${{ matrix.target }} \ + -PGITHUB_PUBLISH_TOKEN="${{ secrets.GITHUB_TOKEN }}" \ + -PsigningInMemoryKey="${{ secrets.SIGNING_KEY }}" \ + -PsigningInMemoryKeyId="${{ secrets.SIGNING_KEY_ID }}" \ + -PsigningInMemoryKeyPassword="${{ secrets.SIGNING_PASSWORD }}" \ + -PcentralPortal.username="${{secrets.SONATYPE_USERNAME}}" \ + -PcentralPortal.password="${{secrets.SONATYPE_PASSWORD}}" diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8ce43f6a..80df3846 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,11 +1,4 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Java CI with Gradle +name: Gradle build & test on: push: @@ -24,15 +17,17 @@ jobs: include: - target: iosSimulatorArm64Test os: macos-latest - - target: jvmTest + - target: testDebugUnitTest + os: ubuntu-latest + - target: testReleaseUnitTest os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | ~/.konan @@ -42,7 +37,11 @@ jobs: with: java-version: '17' distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Build with Gradle - uses: gradle/gradle-build-action@ce999babab2de1c4b649dc15f0ee67e6246c994f - with: - arguments: ${{ matrix.target }} \ No newline at end of file + run: | + ./gradlew \ + ${{ matrix.target }} \ + -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-swift.yml b/.github/workflows/publish-swift.yml new file mode 100644 index 00000000..04900d13 --- /dev/null +++ b/.github/workflows/publish-swift.yml @@ -0,0 +1,21 @@ +name: Publish iOS Swift +on: + workflow_dispatch: +# TODO: Uncomment this if we want to publish on merge to main +# push: +# branches: +# - "main" + +jobs: + call-kmmbridge-publish: + permissions: + contents: write + packages: write + uses: touchlab/KMMBridgeGithubWorkflow/.github/workflows/faktorybuildautoversion.yml@v1.1 + with: + jvmVersion: 17 + versionBaseProperty: LIBRARY_VERSION + publishTask: kmmBridgePublish + secrets: + gradle_params: -PsigningInMemoryKey="${{ secrets.SIGNING_KEY }}" -PsigningInMemoryKeyId="${{ secrets.SIGNING_KEY_ID }}" -PsigningInMemoryKeyPassword="${{ secrets.SIGNING_PASSWORD }}" +# PODSPEC_SSH_KEY: ${{ secrets.PODSPEC_SSH_KEY }} diff --git a/.gitignore b/.gitignore index 878c2cd5..25cb8fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ *.iml .gradle -/local.properties +**/build/ +xcuserdata +!src/**/build/ +local.properties .idea +.fleet .DS_Store -/build -/captures +captures .externalNativeBuild -.cxx -local.properties +**/.cxx +*.xcodeproj/* +**/.swiftpm +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings +Pods/ \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..5f0267b9 --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.3 +import PackageDescription + +let packageName = "powersyncswift" + +let package = Package( + name: packageName, + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: packageName, + targets: [packageName] + ), + ], + targets: [ + .binaryTarget( + name: packageName, + path: "./powersyncswift/build/XCFrameworks/debug/\(packageName).xcframework" + ) + , + ] +) diff --git a/README.md b/README.md new file mode 100644 index 00000000..7c7a3f7d --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +

+ +

+ +[PowerSync](https://powersync.com) is a service and set of SDKs that keeps Postgres databases in sync with on-device SQLite databases. + +# PowerSync Kotlin Multiplatform SDK + +## Alpha release + +This SDK is currently in an alpha release. If you find a bug or issue, please open a [GitHub issue](https://github.com/powersync-ja/powersync-kotlin/issues). Questions or feedback can be posted on +our [community Discord](https://discord.gg/powersync) - we'd love to hear from you. + +## SDK Features + +* Provides real-time streaming of database changes, using Kotlin Coroutines and Flows. +* Offers direct access to the SQLite database, enabling the use of SQL on both client and server + sides. +* Operations are asynchronous, ensuring the user interface remains unblocked. +* Supports concurrent database operations, allowing one write and multiple reads simultaneously. +* Enables subscription to queries for receiving live updates. +* Eliminates the need for client-side database migrations as these are managed automatically. + +Supported KMP targets: Android and iOS. + +## Structure: Packages + +- [core](./core/) + + - This is the Kotlin Multiplatform SDK implementation. + +- [connectors](./connectors/) + + - [SupabaseConnector.kt](./connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt) An example connector implementation tailed for Supabase. The backend connector provides + the connection between your application backend and the PowerSync managed database. It is used to: + 1. Retrieve a token to connect to the PowerSync service. + 2. Apply local changes on your backend application server (and from there, to Postgres). + +## Demo Apps / Example Projects + +The easiest way to test the PowerSync KMP SDK is to run one of our demo applications. + +Demo applications are located in the [`demos/`](./demos) directory. See their respective README's for testing instructions: + +- [demos/hello-powersync](./demos/hello-powersync/README.md): A minimal example demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector. + +- [demos/supabase-todolist](./demos/supabase-todolist/README.md): ** Currently a work in progress ** + A simple to-do list application demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector. + +## Current Limitations / Future work + +The PowerSync Kotlin Multiplatform SDK is currently in an alpha release and is not yet suitable for production use. + +Current limitations: +- Debugging via configurable logging is not yet implemented. +- Integration with SQLDelight schema and API generation (ORM) is not yet supported. +- Supports only a single database file. + +Future work/ideas: +- Improved error handling. +- Attachments helper package. +- Management of DB connections on each platform natively. +- Supporting additional targets (JVM, Wasm). + +## Getting Started + +### Installation + +Add the PowerSync Kotlin Multiplatform SDK to your project by adding the following to your `build.gradle.kts` file: + +```kotlin + +kotlin { + //... + sourceSets { + commonMain.dependencies { + api("com.powersync:core:$powersyncVersion") + } + //... + } +} +``` + +If want to use the Supabase Connector, also add the following to `commonMain.dependencies`: + +```kotlin + implementation("com.powersync:connectors:$powersyncVersion") +``` + +#### Cocoapods + +We recommend using Cocoapods (as opposed to SMP) for iOS targets. Add the following to the `cocoapods` config in your `build.gradle.kts` + +```kotlin +cocoapods { + //... + pod("powersync-sqlite-core") { + linkOnly = true + } + + framework { + isStatic = true + export("com.powersync:core") + } + //... +} +``` + +Note: The `linkOnly` attribute is set to `true` and framework is set to `isStatic = true` to ensure that the `powersync-sqlite-core` binaries are only statically linked. + +### Usage + +The first step is creating a PowerSync account and setting up a PowerSync instance. If you are using Supabase, we have a step-by-step tutorial +available [here](https://docs.powersync.com/integration-guides/supabase-+-powersync). + +For other Postgres backend providers, follow these steps: + +* Sign up for a free PowerSync account + here [https://www.powersync.com/](https://www.powersync.com/). +* Visit the [PowerSync dashboard](https://powersync.journeyapps.com/) to create a PowerSync instance. After signing up you will be prompted to start the onboarding wizard which guides your though the + steps required for this, and find database specific + instructions [here](https://docs.powersync.com/usage/installation/database-setup). Existing users: start the onboarding wizard by navigating to Help > Start guide in the top-right corner. +* Developer documentation for PowerSync is available [here](https://docs.powersync.com/). + +#### 1. Define the schema for the on-device SQLite database. + +You need to set up your schema in your app project. This involves defining your schema in code using the PowerSync syntax. +This schema represents a "view" of the downloaded data. No migrations are required — the schema is applied directly when the PowerSync database is constructed. + +```kotlin +import com.powersync.db.schema.Column +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table + +val schema: Schema = Schema( + listOf( + Table( + "customers", + listOf( + Column.text("name"), + Column.text("email") + ) + ) + ) +) + +``` + +Note: No need to declare a primary key `id` column, as PowerSync will automatically create this. + +#### 2. Implement a backend connector to define how PowerSync communicates with your backend. + +The PowerSync backend connector provides the connection between your application backend and the PowerSync managed database. +It is used to: +1. Retrieve a token to connect to the PowerSync instance. +2. Apply local changes on your backend application server (and from there, to Postgres) + +If you are using Supabase, you can use [SupabaseConnector.kt](./connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt) as a starting point. + +```kotlin +class MyConnector: PowerSyncBackendConnector() { + override suspend fun fetchCredentials(): PowerSyncCredentials { + // implement fetchCredentials to obtain the necessary credentials to connect to your backend + // See an example implementation in connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt + } + + override suspend fun uploadData(database: PowerSyncDatabase) { + // Implement uploadData to send local changes to your backend service + // You can omit this method if you only want to sync data from the server to the client + // See an example implementation in connectors/src/commonMain/kotlin/com/powersync/connectors/SupabaseConnector.kt + // See https://docs.powersync.com/usage/installation/app-backend-setup/writing-client-changes for considerations. + } +} +``` + +#### 3. Initialize the PowerSync database an connect it to the connector, using `PowerSyncBuilder`: + +You need to instantiate the PowerSync database — this is the core managed database. +Its primary functions are to record all changes in the local database, whether online or offline. In addition, it automatically uploads changes to your app backend when connected. + + a. Create platform specific `DatabaseDriverFactory` to be used by the `PowerSyncBuilder` to create the SQLite database driver. + ```kotlin + // Android +val driverFactory = DatabaseDriverFactory(this) + +// iOS +val driverFactory = DatabaseDriverFactory() + ``` + +b. Build a `PowerSyncDatabase` instance using the `PowerSyncBuilder` and the `DatabaseDriverFactory`. The schema you created in a previous step is also used as a parameter: + + ```kotlin + // commonMain +val database = PowerSyncBuilder.from(driverFactory, schema).build() + ``` + +c. Connect the `PowerSyncDatabase` to the backend connector: + + ```kotlin + // commonMain +database.connect(MyConnector()) + ``` + +#### 4. Subscribe to changes in data + +```kotlin +// You can watch any SQL query. This excutes a read query every time the source tables are modified. +fun watchCustomers(): Flow> { + // TODO: implement your UI based on the result set + return database.watch("SELECT * FROM customers", mapper = { cursor -> + User( + id = cursor.getString(0)!!, + name = cursor.getString(1)!!, + email = cursor.getString(2)!! + ) + }) +} +``` + +#### 5. Insert, update, and delete data in the local database + +The `execute` method executes a write query (INSERT, UPDATE, DELETE) and returns the results (if any). + +```kotlin +suspend fun insertCustomer(name: String, email: String) { + database.writeTransaction { + database.execute( + "INSERT INTO customers (id, name, email) VALUES (uuid(), ?, ?)", + listOf(name, email) + ) + } +} + +suspend fun updateCustomer(id: String, name: String, email: String) { + database.execute( + "UPDATE customers SET name = ? WHERE email = ?", + listOf(name, email) + ) +} + +suspend fun deleteCustomer(id: String? = null) { + // If no id is provided, delete the first customer in the database + val targetId = + id ?: database.getOptional("SELECT id FROM customers LIMIT 1", mapper = { cursor -> + cursor.getString(0)!! + }) + ?: return + + database.writeTransaction { + database.execute("DELETE FROM customers WHERE id = ?", listOf(targetId)) + } +} +``` \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index a061ab35..00000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,105 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - kotlin("plugin.serialization") version "1.4.30" -} - - -android { - namespace = "com.example.notessqlite" - compileSdk = 34 - -// sourceSets { -// getByName("main") { -// jniLibs.srcDir("src/main/cpp/jniLibs") -// } -// } - - defaultConfig { - applicationId = "com.example.notessqlite" - minSdk = 28 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } -// externalNativeBuild { -// cmake { -// cppFlags("-O2", "-fexceptions", "-frtti", "-std=c++1y") -// } -// } - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - debug { - ndk { - abiFilters.add("arm64-v8a") - abiFilters.add("x86") - abiFilters.add("x86_64") - } - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.4.3" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - - repositories { - google() - mavenCentral() - maven { - setUrl("https://jitpack.io") - } - } -} - -dependencies { - implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") - implementation("androidx.activity:activity-compose:1.7.0") - implementation(platform("androidx.compose:compose-bom:2023.03.00")) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.core:core-ktx:1.12.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.recyclerview:recyclerview:1.3.2") - implementation("com.github.requery:sqlite-android:3.43.0") - implementation("co.powersync:powersync-sqlite-core:0.1.4") - implementation("io.github.jan-tennert.supabase:postgrest-kt:1.4.1") - implementation("io.ktor:ktor-client-cio:2.3.3") - implementation("io.ktor:ktor-client-content-negotiation:2.3.3") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 481bb434..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt deleted file mode 100644 index f8c95969..00000000 --- a/app/src/androidTest/java/com/example/notessqlite/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.notessqlite - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.notessqlite", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt b/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt deleted file mode 100644 index 42637669..00000000 --- a/app/src/main/java/co/powersync/kotlin/AbstractPowerSyncDatabase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.powersync.kotlin - -abstract class AbstractPowerSyncDatabase { - - /** - * Get the next recorded transaction to upload. - * - * Returns null if there is no data to upload. - * - * Use this from the [PowerSyncBackendConnector.uploadData]` callback. - * - * Once the data have been successfully uploaded, call [CrudTransaction.complete] before - * requesting the next transaction. - * - * Unlike [getCrudBatch], this only returns data from a single transaction at a time. - * All data for the transaction is loaded into memory. - */ - abstract suspend fun getNextCrudTransaction(): Any? -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/Connector.kt b/app/src/main/java/co/powersync/kotlin/Connector.kt deleted file mode 100644 index a8c68639..00000000 --- a/app/src/main/java/co/powersync/kotlin/Connector.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.powersync.kotlin - -import io.github.jan.supabase.SupabaseClient -import kotlinx.datetime.Instant - -class PowerSyncCredentials(val client: SupabaseClient, val endpoint: String, val token: String, val expiresAt: Instant, val userID: String) - -abstract class PowerSyncBackendConnector { - /// Get credentials for PowerSync. - /// - /// This should always fetch a fresh set of credentials - don't use cached - /// values. - /// - /// Return null if the user is not signed in. Throw an error if credentials - /// cannot be fetched due to a network error or other temporary error. - /// - /// This token is kept for the duration of a sync connection. - abstract suspend fun fetchCredentials(): PowerSyncCredentials; - - /// Upload local changes to the app backend. - /// - /// Use [PowerSyncDatabase.getCrudBatch] to get a batch of changes to upload. See [DevConnector] for an example implementation. - /// - /// Any thrown errors will result in a retry after the configured wait period (default: 5 seconds). - abstract suspend fun uploadData(database: AbstractPowerSyncDatabase); -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt b/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt deleted file mode 100644 index 27958e5f..00000000 --- a/app/src/main/java/co/powersync/kotlin/DatabaseHelper.kt +++ /dev/null @@ -1,49 +0,0 @@ -package co.powersync.kotlin - -import android.content.Context -import io.requery.android.database.sqlite.SQLiteCustomExtension -import io.requery.android.database.sqlite.SQLiteDatabase -import io.requery.android.database.sqlite.SQLiteDatabaseConfiguration -import io.requery.android.database.sqlite.SQLiteOpenHelper - -class DatabaseHelper(context: Context): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION){ - companion object { - private const val DATABASE_NAME = "employee.db" - private const val DATABASE_VERSION = 1 - - private const val CREATE_TABLE_EMPLOYEE = """ - CREATE TABLE employee ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - employee_name TEXT, - employee_code TEXT, - employee_image BLOB - ) - """ - } - - override fun onCreate(db: SQLiteDatabase) { - // create the employee table - db.execSQL(CREATE_TABLE_EMPLOYEE) - } - - override fun onOpen(db: SQLiteDatabase?) { - super.onOpen(db) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - // drop the employee table if it exists - db.execSQL("DROP TABLE IF EXISTS employee") - - // create the new employee table - onCreate(db) - } - - // This provides the config each time a connection is made. - override fun createConfiguration(path: String?, openFlags: Int): SQLiteDatabaseConfiguration { - val config = super.createConfiguration(path, openFlags); - - config.customExtensions += SQLiteCustomExtension("libpowersync", "sqlite3_powersync_init"); - - return config; - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt b/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt deleted file mode 100644 index 964e4c5f..00000000 --- a/app/src/main/java/co/powersync/kotlin/PowerSyncDatabase.kt +++ /dev/null @@ -1,568 +0,0 @@ -package co.powersync.kotlin - -import android.database.Cursor -import android.os.StrictMode -import co.powersync.kotlin.bucket.BucketChecksum -import co.powersync.kotlin.bucket.BucketStorageAdapter -import co.powersync.kotlin.bucket.Checkpoint -import co.powersync.kotlin.bucket.CrudEntry -import co.powersync.kotlin.bucket.KotlinBucketStorageAdapter -import co.powersync.kotlin.bucket.SyncDataBatch -import co.powersync.kotlin.bucket.SyncDataBucket -import co.powersync.kotlin.db.Schema -import co.powersync.kotlin.streaming_sync.BucketRequest -import co.powersync.kotlin.streaming_sync.StreamingSyncCheckpointDiff -import co.powersync.kotlin.streaming_sync.StreamingSyncRequest -import co.powersync.kotlin.streaming_sync.isStreamingKeepalive -import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpoint -import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpointComplete -import co.powersync.kotlin.streaming_sync.isStreamingSyncCheckpointDiff -import co.powersync.kotlin.streaming_sync.isStreamingSyncData -import com.russhwolf.settings.BuildConfig -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.timeout -import io.ktor.client.request.get -import io.ktor.client.request.headers -import io.ktor.client.request.preparePost -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.core.isEmpty -import io.ktor.utils.io.core.readBytes -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.decodeFromJsonElement -import java.util.Date - - -class CrudTransaction() - -data class HandleJSONInstructionResult( - var targetCheckpoint: Checkpoint?, - var validatedCheckpoint: Checkpoint?, - var appliedCheckpoint: Checkpoint?, - - var bucketSet: MutableSet?, - var retry: Boolean, -) - -class PowerSyncDatabase( - val dbHelper: DatabaseHelper, - val schema: Schema, - val connector: PowerSyncBackendConnector, -) : AbstractPowerSyncDatabase() { - private var sdkVersion: String? = null - - private var activeHttpResponse: HttpResponse? = null - private var httpClient: HttpClient? = null - private val bucketStorageAdapter: BucketStorageAdapter - private var _lastSyncedAt: Date? = null - - private var isUploadingCrud = false //TODO Thread safe?? - - init { - if (BuildConfig.DEBUG) { - StrictMode.enableDefaults() - } - bucketStorageAdapter = KotlinBucketStorageAdapter(dbHelper) - } - - /** - * Get the next recorded transaction to upload. - * - * Returns null if there is no data to upload. - * - * Use this from the [PowerSyncBackendConnector.uploadData]` callback. - * - * Once the data have been successfully uploaded, call [CrudTransaction.complete] before - * requesting the next transaction. - * - * Unlike [getCrudBatch], this only returns data from a single transaction at a time. - * All data for the transaction is loaded into memory. - */ - override suspend fun getNextCrudTransaction(): CrudTransaction? { - val database = dbHelper.writableDatabase - database.beginTransaction() - try { - val cursor = database.rawQuery( - "SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT 1", - null - ) - if (!cursor.moveToFirst()) { - return null - } - - val txIdIndex = cursor.getColumnIndex("tx_id") - val txId = cursor.getInt(txIdIndex) - - val all: MutableList = mutableListOf() - - println("$txId $all") - - TODO("Not yet implemented") - database.setTransactionSuccessful() - } catch (e: Exception) { - throw e - } finally { - database.endTransaction() - database.close() - } - - - TODO("Not yet implemented") - } - - private var initCompleted: CompletableDeferred? = null - - private suspend fun init() { - initCompleted = CompletableDeferred() - - readSdkVersion() - applySchema() - - httpClient = HttpClient(CIO) { - install(HttpTimeout) - install(ContentNegotiation) - } - - bucketStorageAdapter.init() - - initCompleted?.complete(null) - } - - private fun applySchema() { - val database = dbHelper.readableDatabase - val schemaJson = Json.encodeToString(schema) - val query = "SELECT powersync_replace_schema(?)" - println("Serialized app schema: $schemaJson") - try { - val cursor: Cursor = database.rawQuery(query, arrayOf(schemaJson)) - cursor.moveToNext() - println("Result: ${cursor.columnNames.joinToString(" ")}") - } catch (e: Exception) { - println("Exception $e") - } - - } - - private fun readSdkVersion() { - val database = dbHelper.readableDatabase - - val query = "SELECT powersync_rs_version()" - val cursor: Cursor = database.rawQuery(query, null) - cursor.moveToNext() - val idx = cursor.getColumnIndex("powersync_rs_version()") - - if (idx < 0) { - // TODO, better error required? - throw ArrayIndexOutOfBoundsException( - "Cannot read powersync sdk version, no powersync_rs_version() column in table, Columns: ${ - cursor.columnNames.joinToString( - " " - ) - }" - ) - } - - sdkVersion = cursor.getString(idx) - cursor.close() - } - - init { - println("PowerSyncDatabase Init") - runBlocking { - init() - } - } - - suspend fun connect() { - println("Powersync connecting") - disconnect() - - // If isCompleted returns true, that means that init had completed (Failure also counts as completed) - if (initCompleted?.isCompleted != true) { - initCompleted?.await() - } - - GlobalScope.launch (Dispatchers.IO){ - streamingSyncIteration() - } - } - - private fun disconnect() { - if (activeHttpResponse == null) { - return - } - - println("Disconnecting existing http connection") - activeHttpResponse?.cancel() - } - - /** - * Disconnect and clear the database. - * Use this when logging out. - * The database can still be queried after this is called, but the tables - * would be empty. - */ - fun disconnectAndClear() { - val database = dbHelper.writableDatabase - disconnect() - - // TODO DB name, verify this is necessary with extension - database.beginTransaction() - try { - database.delete("ps_oplog", "1", arrayOf()) - database.delete("ps_crud", "1", arrayOf()) - database.delete("ps_buckets", "1", arrayOf()) - - val existingTableRowsCursor = database.rawQuery( - "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'", - arrayOf() - ) - - val existingTableRows = mutableSetOf() - val nameIdx = existingTableRowsCursor.getColumnIndex("name") - while (existingTableRowsCursor.moveToNext()) { - existingTableRows.add(existingTableRowsCursor.getString(nameIdx)) - } - - if (existingTableRows.isEmpty()) { - return - } - - for (row in existingTableRows) { - database.delete(row, "1", arrayOf()) - } - - database.setTransactionSuccessful() - } finally { - database.endTransaction() - } - } - - private suspend fun streamingSyncIteration() { - val bucketEntries = bucketStorageAdapter.getBucketStates() - val initialBuckets = mutableMapOf() - - val state = HandleJSONInstructionResult( - targetCheckpoint = null, - validatedCheckpoint = null, - appliedCheckpoint = null, - bucketSet = initialBuckets.keys.toMutableSet(), - retry = false - ) - - bucketEntries.forEach { entry -> - run { - initialBuckets[entry.bucket] = entry.op_id - } - } - - val req: Array = - initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) }.toTypedArray() - - streamingSyncRequest( - StreamingSyncRequest( - buckets = req, - include_checksum = true - ) - ).collect { value -> - run { - handleJSONInstruction(value, state) - if (state.retry == true) { - // Disconnect the current connection and re-connect with new token - connect() - } - } - } - } - - // TODO use the return values e.g. {retry=true}, in react native sdk, the return values were for the locks, maybe whe need proper locks here as well - private suspend fun handleJSONInstruction( - jsonString: String, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - println("Received Instruction: $jsonString") - val obj: JsonObject = Json.parseToJsonElement(jsonString) as JsonObject - - when (true) { - isStreamingSyncCheckpoint(obj) -> return handleStreamingSyncCheckpoint(obj, state) - isStreamingSyncCheckpointComplete(obj) -> return handleStreamingSyncCheckpointComplete( - obj, - state - ) - - isStreamingSyncCheckpointDiff(obj) -> return handleStreamingSyncCheckpointDiff( - obj, - state - ) - - isStreamingSyncData(obj) -> return handleStreamingSyncData(obj, state) - isStreamingKeepalive(obj) -> return handleStreamingKeepalive(obj, state) - else -> { - // TODO throw error? - println("Unhandled JSON instruction") - return state - } - } - } - - suspend fun handleStreamingSyncCheckpoint( - jsonObj: JsonObject, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - val checkpoint = - Json.decodeFromJsonElement(jsonObj["checkpoint"] as JsonElement) - state.targetCheckpoint = checkpoint - val bucketsToDelete = state.bucketSet!!.toMutableSet() - val newBuckets = mutableSetOf() - - checkpoint.buckets?.forEach { checksum -> - run { - newBuckets.add(checksum.bucket) - bucketsToDelete.remove(checksum.bucket) - } - } - - if (bucketsToDelete.size > 0) { - println("Removing buckets [${bucketsToDelete.joinToString(separator = ", ")}]") - } - - state.bucketSet = newBuckets - bucketStorageAdapter.removeBuckets(bucketsToDelete.toTypedArray()) - bucketStorageAdapter.setTargetCheckpoint(checkpoint) - - return state - } - - suspend fun handleStreamingSyncCheckpointComplete( - jsonObj: JsonObject, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - println("Checkpoint complete ${Json.encodeToString(state.targetCheckpoint)}") - val result = bucketStorageAdapter.syncLocalDatabase(state.targetCheckpoint!!) - if (!result.checkpointValid) { - // This means checksums failed. Start again with a new checkpoint. - // TODO: better back-off - delay(50) - state.retry = true - // TODO handle retries - return state - } else if (!result.ready) { - // Checksums valid, but need more data for a consistent checkpoint. - // Continue waiting. - // landing here the whole time - } else { - state.appliedCheckpoint = state.targetCheckpoint!!.clone() - println("validated checkpoint ${state.appliedCheckpoint}") - updateSyncStatus(true, Date()) - } - - state.validatedCheckpoint = state.targetCheckpoint - - return state - } - - suspend fun handleStreamingSyncCheckpointDiff( - jsonObj: JsonObject, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint - if (state.targetCheckpoint == null) { - throw Exception("Checkpoint diff without previous checkpoint") - } - val checkpointDiff = - Json.decodeFromJsonElement(jsonObj.get("checkpoint_diff")!!) - - val newBuckets = mutableMapOf() - - state.targetCheckpoint!!.buckets?.forEach { checksum -> - newBuckets[checksum.bucket] = checksum - } - checkpointDiff.updated_buckets.forEach { checksum -> - newBuckets[checksum.bucket] = checksum - } - - checkpointDiff.removed_buckets.forEach { bucket -> newBuckets.remove(bucket) } - - val newCheckpoint = Checkpoint( - last_op_id = checkpointDiff.last_op_id, - buckets = newBuckets.values.toTypedArray(), - write_checkpoint = checkpointDiff.write_checkpoint - ) - - state.targetCheckpoint = newCheckpoint - - state.bucketSet = newBuckets.keys.toMutableSet() - - val bucketsToDelete = checkpointDiff.removed_buckets.clone() - if (bucketsToDelete.isNotEmpty()) { - println("Remove buckets $bucketsToDelete") - } - bucketStorageAdapter.removeBuckets(bucketsToDelete) - bucketStorageAdapter.setTargetCheckpoint(state.targetCheckpoint!!) - - return state - } - - suspend fun handleStreamingSyncData( - jsonObj: JsonObject, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - val buckets = arrayOf(SyncDataBucket.fromRow(jsonObj["data"] as JsonObject)) - bucketStorageAdapter.saveSyncData(SyncDataBatch(buckets)) - - return state - } - - suspend fun handleStreamingKeepalive( - jsonObj: JsonObject, - state: HandleJSONInstructionResult - ): HandleJSONInstructionResult { - val tokenExpiresIn = (jsonObj["token_expires_in"] as JsonPrimitive).content.toInt() - - if (tokenExpiresIn <= 0) { - // Connection would be closed automatically right after this - println("Token expiring reconnect") - state.retry = true - return state - } - triggerCrudUpload() - return state - } - - - private suspend fun streamingSyncRequest(req: StreamingSyncRequest): Flow = flow { - val newLineChar = '\n'.code.toByte() - var instructions: Array = arrayOf() - - val creds = connector.fetchCredentials() - val psPath = creds.endpoint + "/sync/stream" - - val bodyJson = Json.encodeToString(req) - - val statement = httpClient!!.preparePost(psPath) { - contentType(ContentType.Application.Json) - headers { - append(HttpHeaders.Authorization, "Token ${creds.token}") - append("User-Id", creds.userID) - } - timeout { - requestTimeoutMillis = Long.MAX_VALUE - } - setBody(bodyJson) - } - - statement.execute { httpResponse -> - // TODO is this the correct way to stop/disconnect the current connection? - activeHttpResponse = httpResponse - val channel: ByteReadChannel = httpResponse.body() - var buffer: ByteArray = byteArrayOf() - while (!channel.isClosedForRead) { - val readLimit = channel.availableForRead.coerceAtMost(DEFAULT_BUFFER_SIZE).toLong() - val packet = channel.readRemaining(readLimit) - - while (!packet.isEmpty) { - val bytes = packet.readBytes() - bytes.forEach { byte -> - run { - if (byte == newLineChar) { - instructions += String(buffer) - buffer = byteArrayOf() - } else { - buffer += byte - } - } - } - } - - if (instructions.isNotEmpty()) { - instructions.forEach { instruction -> emit(instruction) } - instructions = arrayOf() - } else { - // No more data to read right now and no instructions to emit, wait a bit - // Maybe this isn't even necessary - delay(1000) - } - } - } - } - - fun updateSyncStatus(connected: Boolean, lastSyncedAt: Date? = null) { - _lastSyncedAt = lastSyncedAt ?: _lastSyncedAt - // TODO event firing and listeners - //iterateListeners((cb) => cb.statusChanged?.(new SyncStatus(connected, this.lastSyncedAt))) - } - - suspend fun triggerCrudUpload() { - if (isUploadingCrud) { - return - } - _uploadAllCrud() - } - - // TODO make sure about thread safety - suspend fun _uploadAllCrud() { - isUploadingCrud = true - while (true) { - try { - val done = uploadCrudBatch() - if (done) { - isUploadingCrud = false - break - } - } catch (ex: Exception) { - this.updateSyncStatus(false) - this.isUploadingCrud = false - break - } - } - } - - private suspend fun uploadCrudBatch(): Boolean { - val hasCrud = bucketStorageAdapter.hasCrud() - if (hasCrud) { - uploadCrud() - return false - } else { - bucketStorageAdapter.updateLocalTarget(cb = suspend { getWriteCheckpoint() }) - return true - } - } - - suspend fun uploadCrud() { - // If isCompleted returns true, that means that init had completed (Failure also counts as completed) - if (initCompleted?.isCompleted != true) { - initCompleted?.await() - } - - connector.uploadData(this) - } - - suspend fun getWriteCheckpoint(): String { - val creds = connector.fetchCredentials() - val psPath = creds.endpoint + "/write-checkpoint2.json" - - val response = httpClient!!.get(psPath) { - contentType(ContentType.Application.Json) - headers { - append(HttpHeaders.Authorization, "Token ${creds.token}") - append("User-Id", creds.userID) - } - } - return response.body() as String - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt b/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt deleted file mode 100644 index e224a46d..00000000 --- a/app/src/main/java/co/powersync/kotlin/SupaBaseConnector.kt +++ /dev/null @@ -1,141 +0,0 @@ -package co.powersync.kotlin -import io.github.jan.supabase.SupabaseClient -import io.github.jan.supabase.createSupabaseClient -import io.github.jan.supabase.gotrue.GoTrue -import io.github.jan.supabase.gotrue.gotrue -import io.github.jan.supabase.gotrue.providers.builtin.Email -import io.github.jan.supabase.postgrest.Postgrest -import kotlinx.coroutines.runBlocking - -class SupaBaseConnector: PowerSyncBackendConnector() { - - companion object { - // TODO this needs to be provided by the user/dev - private const val POWERSYNC_URL = "https://6528055654e498b08254c372.powersync.journeyapps.com" - private const val SUPABASE_URL = "https://hlrnpckmfalpixgmpvxb.supabase.co" - private const val SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhscm5wY2ttZmFscGl4Z21wdnhiIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTcxMjA0NzgsImV4cCI6MjAxMjY5NjQ3OH0.kQH5mN7ggAWE_9RGlrd9jnVSUbK3kQle1PIyvMGmRvg" - - private const val TEST_EMAIL = "hello@example.com"; - private const val TEST_PASSWORD = "123456"; - } - - private val supabaseClient: SupabaseClient; - - init { - supabaseClient = createClient() - - runBlocking { - val loginRes = login(); - val creds = fetchCredentials(); - println("Creds $creds") - println("login $loginRes") - } - } - - private fun createClient(): SupabaseClient { - val client = createSupabaseClient( - supabaseUrl = SUPABASE_URL, - supabaseKey = SUPABASE_KEY - ) { - install(GoTrue) - install(Postgrest) - } - - return client - } - - private suspend fun login(): Unit { - val res = supabaseClient.gotrue.loginWith(Email){ - email = TEST_EMAIL - password = TEST_PASSWORD - } - - return res; - } - - override suspend fun fetchCredentials(): PowerSyncCredentials { - val session = supabaseClient.gotrue.currentSessionOrNull(); - - if (session == null) { - throw Exception("Could not fetch Supabase credentials") - } - - if (session.user == null) { - throw Exception("No user data") - } - - println("session expires at " + session.expiresAt); - - return PowerSyncCredentials( - client = supabaseClient, - endpoint = POWERSYNC_URL, - token = session.accessToken, - expiresAt = session.expiresAt, - // !! required because error: Smart cast to 'UserInfo' is impossible, because 'session.user' is a public API property declared in different module - // TODO figure out what error is about - userID = session.user!!.id - ); - } - - override suspend fun uploadData(database: AbstractPowerSyncDatabase) { - val transaction = database.getNextCrudTransaction() ?: return - - } - - // TODO implement uploadData -// suspend fun uploadData(database: AbstractPowerSyncDatabase): Promise { -// const transaction = await database.getNextCrudTransaction(); -// -// if (!transaction) { -// return; -// } -// -// let lastOp: CrudEntry | null = null; -// try { -// // Note: If transactional consistency is important, use database functions -// // or edge functions to process the entire transaction in a single call. -// for (let op of transaction.crud) { -// lastOp = op; -// const table = this.supabaseClient.from(op.table); -// switch (op.op) { -// case UpdateType.PUT: -// const record = { ...op.opData, id: op.id }; -// const { error } = await table.upsert(record); -// if (error) { -// throw new Error(`Could not upsert data to Supabase ${JSON.stringify(error)}`); -// } -// break; -// case UpdateType.PATCH: -// await table.update(op.opData).eq('id', op.id); -// break; -// case UpdateType.DELETE: -// await table.delete().eq('id', op.id); -// break; -// } -// } -// -// await transaction.complete(); -// } catch (ex: any) { -// console.debug(ex); -// if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some((regex) => regex.test(ex.code))) { -// /** -// * Instead of blocking the queue with these errors, -// * discard the (rest of the) transaction. -// * -// * Note that these errors typically indicate a bug in the application. -// * If protecting against data loss is important, save the failing records -// * elsewhere instead of discarding, and/or notify the user. -// */ -// console.error(`Data upload error - discarding ${lastOp}`, ex); -// await transaction.complete(); -// } else { -// // Error may be retryable - e.g. network error or temporary server error. -// // Throwing an error here causes this call to be retried after a delay. -// throw ex; -// } -// } -// } - - - -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt b/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt deleted file mode 100644 index 76c57af4..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/BucketStorageAdapter.kt +++ /dev/null @@ -1,78 +0,0 @@ -package co.powersync.kotlin.bucket - -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -class SyncLocalDatabaseResult ( - val checkpointValid: Boolean, - val ready: Boolean, - val failures: Array ?= null -) - -@Serializable -data class BucketChecksum ( - val bucket: String, - /** - * 32-bit unsigned hash. - */ - val checksum: Int, - - /** - * Count of operations - informational only. - */ - val count: Int ?= null -) - -@Serializable -data class Checkpoint ( - val last_op_id: String, - val buckets: Array ?= null, - val write_checkpoint: String ?= null, -) { - fun clone(): Checkpoint { - return Checkpoint(last_op_id, buckets?.clone(), write_checkpoint) - } - - override fun toString(): String { - return Json.encodeToString(this) - } -} - -class BucketState ( - val bucket: String, - val op_id: String -) - -abstract class BucketStorageAdapter { - abstract suspend fun init() - abstract suspend fun saveSyncData(batch: SyncDataBatch) - abstract suspend fun removeBuckets(buckets: Array) - - // operation = change to data, checkpoint = where in the oplog we are - abstract suspend fun setTargetCheckpoint(checkpoint: Checkpoint) - - abstract fun startSession() - - abstract suspend fun getBucketStates(): Array - - abstract suspend fun syncLocalDatabase(checkpoint: Checkpoint): SyncLocalDatabaseResult - - abstract suspend fun hasCrud(): Boolean - abstract suspend fun getCrudBatch(limit: Int?): CrudBatch? - - abstract suspend fun hasCompletedSync(): Boolean - abstract suspend fun updateLocalTarget(cb: suspend () -> String): Boolean - - /** - * Exposed for tests only. - */ - abstract suspend fun autoCompact() - - /** - * Exposed for tests only. - */ - abstract suspend fun forceCompact() - - abstract fun getMaxOpId(): String -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt b/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt deleted file mode 100644 index c7d03202..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/CrudBatch.kt +++ /dev/null @@ -1,7 +0,0 @@ -package co.powersync.kotlin.bucket - -class CrudBatch ( - val crud: Array, - val haveMore: Boolean, - val complete: (writeCheckpoint: Boolean?) -> Unit -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt b/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt deleted file mode 100644 index 777711ed..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/CrudEntry.kt +++ /dev/null @@ -1,15 +0,0 @@ -package co.powersync.kotlin.bucket - -enum class UpdateType { - PUT , - PATCH, - DELETE -} -class CrudEntry ( - val clientId: Int, - val op: UpdateType, - val table: String, - val id: String, - val transactionId: Int?, - val opData: Map? -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt b/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt deleted file mode 100644 index 6a64412c..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/KotlinBucketStorageAdapter.kt +++ /dev/null @@ -1,475 +0,0 @@ -package co.powersync.kotlin.bucket - -import android.content.ContentValues -import android.database.Cursor -import co.powersync.kotlin.DatabaseHelper -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.util.UUID - -val COMPACT_OPERATION_INTERVAL = 1_000 - -@Serializable -data class ValidatedCheckpointResult( - val valid: Boolean, - val failed_buckets: Array -) - -class KotlinBucketStorageAdapter( - private val dbHelp: DatabaseHelper, -) : BucketStorageAdapter() { - companion object { - private const val MAX_OP_ID = "9223372036854775807" - } - - private val tableNames: MutableSet = mutableSetOf() - - // TODO thread safe?! - private var _hasCompletedSync = false - - // TODO thread safe?! - private var pendingBucketDeletes = false - - /** - * Count up, and do a compact on startup. - */ - private var compactCounter = COMPACT_OPERATION_INTERVAL - override suspend fun init() { - _hasCompletedSync = false - - readTableNames() - } - - private fun readTableNames() { - val database = dbHelp.readableDatabase - tableNames.clear() - // Query to get existing table names - val query = "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'" - val cursor: Cursor = database.rawQuery(query, null) - val nameIndex = cursor.getColumnIndex("name") - while (cursor.moveToNext()) { - val name = cursor.getString(nameIndex) - tableNames.add(name) - } - - cursor.close() - } - - override suspend fun saveSyncData(batch: SyncDataBatch) { - hasCompletedSync() - val database = dbHelp.writableDatabase - database.beginTransaction() - - try { - var count = 0 - for (b in batch.buckets) { - - val bucketJsonStr = Json.encodeToString(SyncDataBatch(arrayOf(b))) - val values = ContentValues().apply { - put("op", "save") - // TODO what prevents us from just using batch directly? instead of wrapping each bucket into each own batch - put("data", bucketJsonStr) - } - - val insertId = database.insert("powersync_operations", null, values) - - if (insertId == -1L) { - // Error occurred according to docs - println("Something went wrong!") - } - - count += b.data.size - compactCounter += count - } - - database.setTransactionSuccessful() - } finally { - database.endTransaction() - database.close() - } - - hasCompletedSync() - println("Finished inserting into powersync_operations") - } - - override suspend fun removeBuckets(buckets: Array) { - for (bucket in buckets) { - deleteBucket(bucket) - } - } - - fun deleteBucket(bucket: String) { - // Delete a bucket, but allow it to be re-created. - // To achieve this, we rename the bucket to a new temp name, and change all ops to remove. - // By itself, this new bucket would ensure that the previous objects are deleted if they contain no more references. - // If the old bucket is re-added, this new bucket would have no effect. - val newName = "\$delete_${bucket}_${UUID.randomUUID()}" - println("Deleting bucket $bucket") - - val database = dbHelp.writableDatabase - database.beginTransaction() - - try { - val values = ContentValues().apply { - put("op", "\"${OpTypeEnum.REMOVE}\"") - put("data", "NULL") - } - - val where = "op=\"${OpTypeEnum.PUT}\" AND superseded=0 AND bucket=?" - - val args = arrayOf(bucket) - - database.update("ps_oplog", values, where, args) - - // Rename the bucket to the new name - database.update( - "ps_oplog", - ContentValues().apply { - put("bucket", newName) - }, - "bucket=?", - arrayOf(bucket) - ) - - database.delete("ps_buckets", "name = ?", arrayOf(bucket)) - val res4Cursor = database.rawQuery( - "INSERT INTO ps_buckets(name, pending_delete, last_op) SELECT ?, 1, IFNULL(MAX(op_id), 0) FROM ps_oplog WHERE bucket = ?", - arrayOf(newName, newName) - ) - - res4Cursor.close() - database.setTransactionSuccessful() - }catch(e: Exception) { - println("deleteBucket Failed ${e.message}") - } finally { - database.endTransaction() - } - println("done deleting bucket") - pendingBucketDeletes = true - } - - override suspend fun setTargetCheckpoint(checkpoint: Checkpoint) { - // No-op for now - } - - override fun startSession() { - // Do nothing, yet - } - - override suspend fun getBucketStates(): Array { - val database = dbHelp.readableDatabase - - val buckets = mutableSetOf() - val query = - "SELECT name as bucket, cast(last_op as TEXT) as op_id FROM ps_buckets WHERE pending_delete = 0" - val cursor: Cursor = database.rawQuery(query, null) - val bucketIndex = cursor.getColumnIndex("bucket") - val opIdIndex = cursor.getColumnIndex("op_id") - while (cursor.moveToNext()) { - val bucket = cursor.getString(bucketIndex) - val opId = cursor.getString(opIdIndex) - buckets.add(BucketState(bucket, opId)) - } - - cursor.close() - - // TODO maybe we can stick to set? or list TODO x2 read up on list vs map vs set in Kotlin world - return buckets.toTypedArray() - } - - fun validateChecksums(checkpoint: Checkpoint): SyncLocalDatabaseResult { - val database = dbHelp.readableDatabase - - val query = "SELECT powersync_validate_checkpoint(?) as result" - val dataStrArr = arrayOf(Json.encodeToString(checkpoint)) - - val resJsonStr = database.stringForQuery(query, dataStrArr) - - if (resJsonStr.isEmpty()) { - return SyncLocalDatabaseResult( - checkpointValid = false, - ready = false, - failures = arrayOf() - ) - } - - val result = Json.decodeFromString(resJsonStr) - - if (result.valid) { - return SyncLocalDatabaseResult( - checkpointValid = true, - ready = true - ) - } - - return SyncLocalDatabaseResult( - checkpointValid = false, - ready = false, - failures = result.failed_buckets - ) - } - - override suspend fun syncLocalDatabase(checkpoint: Checkpoint): SyncLocalDatabaseResult { - val r = validateChecksums(checkpoint) - - if (!r.checkpointValid) { - // TODO error message here - println("Checksums failed for ${r.failures?.joinToString( separator =", " )}") - if (r.failures?.isNotEmpty() == true) { - for (b in r.failures) { - deleteBucket(b) - } - } - - return SyncLocalDatabaseResult( - ready = false, - checkpointValid = false, - failures = r.failures - ) - } - - val bucketNames: List? = checkpoint.buckets?.map { b -> b.bucket } - - val database = dbHelp.writableDatabase - database.beginTransaction() - - try { - val query = - "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))" - val bucketJson = Json.encodeToString(bucketNames) - database.rawQuery( - query, - arrayOf(checkpoint.last_op_id, bucketJson) - ) - - if (checkpoint.write_checkpoint?.isNotEmpty() == true) { - val query2 = "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'" - database.rawQuery(query2, arrayOf( checkpoint.write_checkpoint)) - } - - database.setTransactionSuccessful() - } finally { - database.endTransaction() - } - - val valid = updateObjectsFromBuckets() - if (!valid) { - println("Not at a consistent checkpoint - cannot update local db") - return SyncLocalDatabaseResult( - ready = false, - checkpointValid = true - ) - } - - forceCompact() - - return SyncLocalDatabaseResult( - ready = true, - checkpointValid = true - ) - } - - fun updateObjectsFromBuckets(): Boolean{ - /** - * It's best to execute this on the same thread - * https://github.com/journeyapps/powersync-sqlite-core/blob/40554dc0e71864fe74a0cb00f1e8ca4e328ff411/crates/sqlite/sqlite/sqlite3.h#L2578 - */ - - val database = dbHelp.writableDatabase - database.beginTransaction() - try { - val res = database.insert("powersync_operations", null, ContentValues().apply { - put("op", "sync_local") - put("data", "") - }) - - database.setTransactionSuccessful() - - return res == 1L - } - catch (e:Exception){ - // TODO proper error message - println("updateObjectsFromBuckets Error ${e.message}") - } - finally { - database.endTransaction() - database.close() - } - - return false - } - - override suspend fun hasCrud(): Boolean { - val database = dbHelp.readableDatabase - - val query = "SELECT 1 FROM ps_crud LIMIT 1" - val cursor: Cursor = database.rawQuery(query, null) - - val hasCrud = cursor.count > 0 - cursor.close() - return hasCrud - } - - override suspend fun getCrudBatch(limit: Int?): CrudBatch? { - TODO("Not yet implemented") - } - - override suspend fun hasCompletedSync(): Boolean { - if (_hasCompletedSync) { - return true; - } - - val database = dbHelp.readableDatabase; - val cursor = database.rawQuery("SELECT name, last_applied_op FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1", null) - - val completed = cursor.count > 0; - - if (completed) { - this._hasCompletedSync = true; - } - - database.close() - return completed; - } - - override suspend fun updateLocalTarget(cb: suspend () -> String): Boolean { - val database = dbHelp.writableDatabase - - val rs1Cursor = database.rawQuery( - "SELECT target_op FROM ps_buckets WHERE name = '\$local' AND target_op = ?", - arrayOf( MAX_OP_ID) - ) - - if (rs1Cursor.count == 0) { - rs1Cursor.close() - database.close() - // Nothing to update - return false - } - rs1Cursor.close() - - val rsCursor = database.rawQuery( - "SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'", arrayOf() - ) - - if (rsCursor.count == 0) { - rsCursor.close() - database.close() - // Nothing to update - return false - } - - rsCursor.moveToFirst() - val seqIndex = rsCursor.getColumnIndex("seq") - val seqBefore = rsCursor.getInt(seqIndex) - - val opId = cb() - - println("[updateLocalTarget] Updating target to checkpoint $opId"); - - database.beginTransaction() - - try { - val anyData = database.stringForQuery("SELECT 1 FROM ps_crud LIMIT 1", null) - if( anyData.isNotEmpty()) { - // if isNotEmpty - println("updateLocalTarget ps crud is not empty") - return false; - } - - val cursor = database.rawQuery("SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'", null) - - if(cursor.moveToFirst()){ - // assert isNotEmpty - throw Exception("SQlite Sequence should not be empty") - } - - val seqIndex = cursor.getColumnIndex("item") - val item = cursor.getInt(seqIndex) - val seqAfter = cursor.getInt(seqIndex) - -// println("seqAfter ${Json.encodeToString()}", JSON.stringify(rs.rows?.item(0))); - } - catch (e: Exception){ - throw e - } - finally { - database.endTransaction() - database.close() - } - -// this.logger.debug('seqAfter', JSON.stringify(rs.rows?.item(0))); -// if (seqAfter != seqBefore) { -// this.logger.debug('seqAfter != seqBefore', seqAfter, seqBefore); -// // New crud data may have been uploaded since we got the checkpoint. Abort. -// return false; -// } -// -// const response = await tx.executeAsync("UPDATE ps_buckets SET target_op = ? WHERE name='$local'", [opId]); -// this.logger.debug(['[updateLocalTarget] Response from updating target_op ', JSON.stringify(response)]); -// return true; -// }); - - TODO("Not yet implemented") - } - - suspend fun deletePendingBuckets() { - if (pendingBucketDeletes != false) { - val database = dbHelp.writableDatabase - - database.beginTransaction() - try { - database.execSQL("DELETE FROM ps_oplog WHERE bucket IN (SELECT name FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op)") - database.execSQL("DELETE FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op") - - // Executed once after start-up, and again when there are pending deletes. - pendingBucketDeletes = false - database.setTransactionSuccessful() - } - finally { - database.endTransaction() - database.close() - } - } - } - - private fun clearRemoveOps(){ - if (compactCounter < COMPACT_OPERATION_INTERVAL) { - return; - } - - val database = dbHelp.writableDatabase - - database.beginTransaction() - try { - database.insert("powersync_operations", null,ContentValues().apply { - put("op", "clear_remove_ops") - put("data", "") - } ) - - compactCounter = 0 - database.setTransactionSuccessful() - } - finally { - database.endTransaction() - database.close() - } - } - - override suspend fun autoCompact() { - deletePendingBuckets() - clearRemoveOps() - } - - override suspend fun forceCompact() { - compactCounter = COMPACT_OPERATION_INTERVAL - pendingBucketDeletes = true - - autoCompact() - } - - override fun getMaxOpId(): String { - return MAX_OP_ID - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt b/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt deleted file mode 100644 index d3828b9e..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/OpType.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.powersync.kotlin.bucket - -import kotlinx.serialization.Serializable - -@Serializable -enum class OpTypeEnum { - CLEAR, - MOVE, - PUT, - REMOVE -} diff --git a/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt b/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt deleted file mode 100644 index 8007e150..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/OplogEntry.kt +++ /dev/null @@ -1,56 +0,0 @@ -package co.powersync.kotlin.bucket - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -@Serializable -data class OplogEntryJSON ( - val op_id: String, - val op: String, - val object_type: String, - val object_id: String, - val checksum: Int, - val data: JsonObject, - val subkey: String -) - -@Serializable -data class OplogEntry ( - val op_id: String, - val op: OpTypeEnum, - val checksum: Long, - /// Together with rowType and rowId, this uniquely identifies a source entry - /// per bucket in the oplog. There may be multiple source entries for a single - /// "rowType + rowId" combination. - val subkey: String?= null, - val object_type: String?, - val object_id: String?, - val data: MutableMap? -) { - companion object { - fun fromRow (row: JsonObject): OplogEntry { - - var dataMap: MutableMap? = null - if(row["data"] !is JsonNull){ - dataMap= mutableMapOf() - val dataObj = row["data"] as JsonObject - dataObj.forEach{i -> dataMap[i.key] = (i.value as JsonPrimitive).content } - } - - val opType: OpTypeEnum = OpTypeEnum.valueOf((row["op"] as JsonPrimitive).content) - val op = opType - - return OplogEntry( - object_id = (row["object_id"] as JsonPrimitive).content, - checksum = (row["checksum"] as JsonPrimitive).content.toLong(), - object_type = (row["object_type"] as JsonPrimitive).content, - op_id = (row["op_id"] as JsonPrimitive).content, - subkey = (row["subkey"] as JsonPrimitive).content, - data = dataMap, - op = op - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt b/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt deleted file mode 100644 index 671298c5..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBatch.kt +++ /dev/null @@ -1,6 +0,0 @@ -package co.powersync.kotlin.bucket - -import kotlinx.serialization.Serializable - -@Serializable -data class SyncDataBatch(val buckets: Array) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt b/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt deleted file mode 100644 index 8371b323..00000000 --- a/app/src/main/java/co/powersync/kotlin/bucket/SyncDataBucket.kt +++ /dev/null @@ -1,51 +0,0 @@ -package co.powersync.kotlin.bucket - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -@Serializable -data class SyncDataBucketJSON( - val bucket: String, - val has_more: Boolean, // TODO is this optional? - val after: String, // TODO is this optional? - val next_after: String,// TODO is this optional? - val data: Array -) - -@Serializable -data class SyncDataBucket( - - val bucket: String, - val data: Array, - /** - * True if the response does not contain all the data for this bucket, and another request must be made. - */ - val has_more: Boolean = false, - /** - * The `after` specified in the request. - */ - val after: String ?= null, - /** - * Use this for the next request. - */ - val next_after: String ?= null - - -) { - companion object { - fun fromRow(row: JsonObject): SyncDataBucket { - val data = (row["data"] as JsonArray).map { i -> OplogEntry.fromRow(i as JsonObject) } - .toTypedArray() - - return SyncDataBucket( - bucket = (row["bucket"] as JsonPrimitive).content, - data = data, - after = (row["after"] as JsonPrimitive).content, - has_more = (row["has_more"] as JsonPrimitive).content.toBoolean(), - next_after = (row["next_after"] as JsonPrimitive).content - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/db/Column.kt b/app/src/main/java/co/powersync/kotlin/db/Column.kt deleted file mode 100644 index e5620ddb..00000000 --- a/app/src/main/java/co/powersync/kotlin/db/Column.kt +++ /dev/null @@ -1,16 +0,0 @@ -package co.powersync.kotlin.db - -import kotlinx.serialization.Serializable - -@Serializable -enum class ColumnType { - TEXT, - INTEGER, - REAL -} - -@Serializable -class Column ( - val name: String, - val type: ColumnType? -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/db/Index.kt b/app/src/main/java/co/powersync/kotlin/db/Index.kt deleted file mode 100644 index dbc47250..00000000 --- a/app/src/main/java/co/powersync/kotlin/db/Index.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.powersync.kotlin.db - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@Serializable -class Index @OptIn(ExperimentalSerializationApi::class) constructor( - val name: String, - @EncodeDefault val columns: Array = arrayOf() -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt b/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt deleted file mode 100644 index 206b4435..00000000 --- a/app/src/main/java/co/powersync/kotlin/db/IndexedColumn.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.powersync.kotlin.db - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@Serializable -class IndexedColumn @OptIn(ExperimentalSerializationApi::class) constructor( - val name: String, - @EncodeDefault val ascending: Boolean = true, - @EncodeDefault val type: ColumnType = ColumnType.TEXT - // TODO the type needs to come from the actual listed column e.g. - // toJSON(table: Table) { - // return { - // ... - // type: table.columns.find((column) => column.name === this.name)?.type ?? ColumnType.TEXT - // }; - // } -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/db/Schema.kt b/app/src/main/java/co/powersync/kotlin/db/Schema.kt deleted file mode 100644 index 1224b80e..00000000 --- a/app/src/main/java/co/powersync/kotlin/db/Schema.kt +++ /dev/null @@ -1,6 +0,0 @@ -package co.powersync.kotlin.db - -import kotlinx.serialization.Serializable - -@Serializable -data class Schema(val tables:Array) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/db/Table.kt b/app/src/main/java/co/powersync/kotlin/db/Table.kt deleted file mode 100644 index 6349a58a..00000000 --- a/app/src/main/java/co/powersync/kotlin/db/Table.kt +++ /dev/null @@ -1,14 +0,0 @@ -package co.powersync.kotlin.db - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@Serializable -class Table @OptIn(ExperimentalSerializationApi::class) constructor( - val name: String, - val columns: Array, - @EncodeDefault val indexes: Array = arrayOf(), - @EncodeDefault val localOnly: Boolean = false, - @EncodeDefault val insertOnly: Boolean = false, -) \ No newline at end of file diff --git a/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt b/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt deleted file mode 100644 index 33cb656f..00000000 --- a/app/src/main/java/co/powersync/kotlin/streaming_sync/streaming-sync-types.kt +++ /dev/null @@ -1,66 +0,0 @@ -package co.powersync.kotlin.streaming_sync - -import co.powersync.kotlin.bucket.BucketChecksum -import co.powersync.kotlin.bucket.SyncDataBucketJSON -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -data class StreamingSyncDataJSON( - val data: SyncDataBucketJSON -) - -data class StreamingSyncCheckpointDiff( - val last_op_id: String, - val updated_buckets: Array, - val removed_buckets: Array, - val write_checkpoint: String -) - -@Serializable -data class BucketRequest( - val name: String, - - /** - * Base-10 number. Sync all data from this bucket with op_id > after. - */ - val after: String -) - -@Serializable -data class StreamingSyncRequest( - /** - * Existing bucket states. - */ - val buckets: Array?, - - /** - * If specified, limit the response to only include these buckets. - */ - val only: Array? = null, - - /** - * Whether or not to compute a checksum for each checkpoint - */ - val include_checksum: Boolean -) - - -fun isStreamingSyncData(obj: JsonObject): Boolean { - return obj.containsKey("data") -} - -fun isStreamingKeepalive(obj: JsonObject): Boolean { - return obj.containsKey("token_expires_in") -} - -fun isStreamingSyncCheckpoint(obj: JsonObject): Boolean { - return obj.containsKey("checkpoint") -} - -fun isStreamingSyncCheckpointComplete(obj: JsonObject): Boolean { - return obj.containsKey("checkpoint_complete") -} - -fun isStreamingSyncCheckpointDiff(obj: JsonObject): Boolean { - return obj.containsKey("checkpoint_diff") -} \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt b/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt deleted file mode 100644 index 04c42b4a..00000000 --- a/app/src/main/java/com/example/notessqlite/EmployeeAdapter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.notessqlite - -import android.graphics.BitmapFactory -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView - -class EmployeeAdapter(private val employeeList: List) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmployeeViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_employee, parent, false) - return EmployeeViewHolder(view) - } - - override fun onBindViewHolder(holder: EmployeeViewHolder, position: Int) { - val employee = employeeList[position] - holder.bind(employee) - } - - override fun getItemCount(): Int = employeeList.size - - class EmployeeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - - fun bind(employee: EmployeeDatabase.Employee) { - itemView.findViewById(R.id.tv_employee_name).text = employee.name - itemView.findViewById(R.id.tv_employee_code).text = employee.code - - itemView.findViewById(R.id.iv_employee_image) - .setImageBitmap(BitmapFactory.decodeByteArray(employee.image, 0, employee.image.size)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt b/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt deleted file mode 100644 index b12fe0d8..00000000 --- a/app/src/main/java/com/example/notessqlite/EmployeeDatabase.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.example.notessqlite - -import android.content.Context -import co.powersync.kotlin.DatabaseHelper -import co.powersync.kotlin.PowerSyncDatabase -import co.powersync.kotlin.SupaBaseConnector -import co.powersync.kotlin.db.Column -import co.powersync.kotlin.db.ColumnType -import co.powersync.kotlin.db.Index -import co.powersync.kotlin.db.IndexedColumn -import co.powersync.kotlin.db.Schema -import co.powersync.kotlin.db.Table -import kotlinx.coroutines.runBlocking - -class EmployeeDatabase(context: Context) { - - companion object { - private val SCHEMA: Schema = Schema( tables = arrayOf( - Table( - name = "todos", - columns = arrayOf( - Column(name = "list_id", type = ColumnType.TEXT ), - Column(name = "created_at", type = ColumnType.TEXT ), - Column(name = "completed_at", type = ColumnType.TEXT ), - Column(name = "description", type = ColumnType.TEXT ), - Column(name = "completed", type = ColumnType.INTEGER ), - Column(name = "created_by", type = ColumnType.TEXT ), - Column(name = "completed_by", type = ColumnType.TEXT) - ), - indexes = arrayOf( - Index( - name = "list", - columns= arrayOf( - IndexedColumn(name = "list_id") - )) - ) - ), - Table( - name= "lists", - columns = arrayOf( - Column(name = "created_at", type = ColumnType.TEXT), - Column(name = "name", type = ColumnType.TEXT), - Column(name = "owner_id", type = ColumnType.TEXT) - ) - ) - ) - ) - } - - private val databaseHelper = DatabaseHelper(context) - private val supaBaseClient = SupaBaseConnector(); - private val powerSyncDatabase = PowerSyncDatabase( - dbHelper = databaseHelper, - schema = SCHEMA, - connector = supaBaseClient - ); - - fun init(){ - runBlocking { - powerSyncDatabase.connect(); - } - } - -// fun addEmployee(employee: Employee){ -// insert(employee.name, employee.code, employee.image); -// } - -// fun getAllEmployees(): List{ -//// return getAll() -// } - -// fun insert(name: String, code: String, image: ByteArray) { -// // get the writable database -// val db = databaseHelper.writableDatabase -// -// // create the ContentValues object -// val values = ContentValues().apply{ -// put("employee_name", name) -// put("employee_code", code) -// put("employee_image", image) -// } -// -// // insert the data into the table -// db.insert("employee", null, values) -// -// // close the database connection -// db.close() -// } - -// @SuppressLint("Range") -// fun getAll(): List { -// val list = mutableListOf() -// -// // get the readable database -// val db = databaseHelper.readableDatabase -// -// // select all data from the table -// val cursor = db.rawQuery("SELECT * FROM employee", null) -// -// // iterate through the cursor and add the data to the list -// while (cursor.moveToNext()) { -// val name = cursor.getString(cursor.getColumnIndex("employee_name")) -// val code = cursor.getString(cursor.getColumnIndex("employee_code")) -// val image = cursor.getBlob(cursor.getColumnIndex("employee_image")) -// list.add(Employee(name, code, image)) -// } -// -// // close the cursor and database connection -// cursor.close() -// db.close() -// -// return list -// } - -// fun update(id: Int, name: String, code: String, image: ByteArray) { -// // get the writable database -// val db = databaseHelper.writableDatabase -// -// // create the ContentValues object -// val values = ContentValues().apply { -// put("employee_name", name) -// put("employee_code", code) -// put("employee_image", image) -// } -// -// // update the data in the table -// db.update("employee", values, "id = ?", arrayOf(id.toString())) -// -// // close the database connection -// db.close() -// } - -// fun delete(id: Int) { -// // get the writable database -// val db = databaseHelper.writableDatabase -// -// // delete the data from the table -// db.delete("employee", "id = ?", arrayOf(id.toString())) -// -// // close the database connection -// db.close() -// } - - data class Employee(val name: String, val code: String, val image: ByteArray) - -} \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/MainActivity.kt b/app/src/main/java/com/example/notessqlite/MainActivity.kt deleted file mode 100644 index 347279c4..00000000 --- a/app/src/main/java/com/example/notessqlite/MainActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.notessqlite - -import android.app.Activity -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable -import android.os.Bundle -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import java.io.ByteArrayOutputStream - -class MainActivity : Activity() { - - private lateinit var employeeRecyclerView: RecyclerView - private lateinit var employeeAdapter: EmployeeAdapter - - private lateinit var database: EmployeeDatabase - -// private lateinit var nativeSqliteSDK: NativeSqliteSDK; - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - -// nativeSqliteSDK = NativeSqliteSDK(); -// nativeSqliteSDK.initialize(); - - employeeRecyclerView = findViewById(R.id.rv_employee_list) - - employeeAdapter = EmployeeAdapter(emptyList()) - employeeRecyclerView.adapter = employeeAdapter - - database = EmployeeDatabase(this) - database.init() - - // Add sample data to the database - //database.addEmployee(EmployeeDatabase.Employee("John Smith", "JNS001", getByteArrayFromDrawable(R.drawable.employee1))) - //database.addEmployee(EmployeeDatabase.Employee("Jane Doe", "JDO002", getByteArrayFromDrawable(R.drawable.employee2))) - //database.addEmployee(EmployeeDatabase.Employee("Bob Johnson", "BJH003", getByteArrayFromDrawable(R.drawable.employee3))) - - // Get all employees from the database and display them in the RecyclerView -// val employeeList = database.getAllEmployees() -// employeeAdapter = EmployeeAdapter(employeeList) - employeeRecyclerView.adapter = employeeAdapter - employeeRecyclerView.layoutManager = LinearLayoutManager(this) - - println("Done with setup") - } - - // Helper function to convert a drawable resource to a byte array - private fun getByteArrayFromDrawable(drawableId: Int): ByteArray { - val drawable = ContextCompat.getDrawable(this, drawableId) ?: throw IllegalArgumentException("Drawable not found") - val bitmap = (drawable as BitmapDrawable).bitmap - val stream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - return stream.toByteArray() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt deleted file mode 100644 index fb8b4596..00000000 --- a/app/src/main/java/com/example/notessqlite/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.notessqlite.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt deleted file mode 100644 index f61f882c..00000000 --- a/app/src/main/java/com/example/notessqlite/ui/theme/Theme.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.notessqlite.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun NotesSQLiteTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt b/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt deleted file mode 100644 index 711fe129..00000000 --- a/app/src/main/java/com/example/notessqlite/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.notessqlite.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/res/drawable/employee1.png b/app/src/main/res/drawable/employee1.png deleted file mode 100644 index bf29b72d..00000000 Binary files a/app/src/main/res/drawable/employee1.png and /dev/null differ diff --git a/app/src/main/res/drawable/employee2.png b/app/src/main/res/drawable/employee2.png deleted file mode 100644 index 52b65a1a..00000000 Binary files a/app/src/main/res/drawable/employee2.png and /dev/null differ diff --git a/app/src/main/res/drawable/employee3.png b/app/src/main/res/drawable/employee3.png deleted file mode 100644 index 969a46ef..00000000 Binary files a/app/src/main/res/drawable/employee3.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index 284624f4..00000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person2.xml b/app/src/main/res/drawable/ic_person2.xml deleted file mode 100644 index afab3920..00000000 --- a/app/src/main/res/drawable/ic_person2.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person3.xml b/app/src/main/res/drawable/ic_person3.xml deleted file mode 100644 index fd5544a9..00000000 --- a/app/src/main/res/drawable/ic_person3.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person4.xml b/app/src/main/res/drawable/ic_person4.xml deleted file mode 100644 index 2c464330..00000000 --- a/app/src/main/res/drawable/ic_person4.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 879879bf..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_employee.xml b/app/src/main/res/layout/item_employee.xml deleted file mode 100644 index a3eedfda..00000000 --- a/app/src/main/res/layout/item_employee.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78e..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index f8c6127d..00000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index a9898618..00000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Notes SQLite - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index c1fca5b6..00000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - -