diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e864786..41bbbaa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,26 +1,60 @@
-name: Build
+name: CI
on:
push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
+ branches: [ "main", "feature/**" ]
+ paths-ignore:
+ - "**.md"
+ - "docs/**"
-jobs:
- build:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ android:
runs-on: ubuntu-latest
+ timeout-minutes: 25
steps:
- - uses: actions/checkout@v3
- - name: set up JDK 17
- uses: actions/setup-java@v3
+ - uses: actions/checkout@v4
+ - name: set up JDK 21
+ uses: actions/setup-java@v4
with:
- java-version: '17'
+ java-version: '21'
distribution: 'temurin'
- cache: gradle
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ validate-wrappers: true
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
- name: Build with Gradle
- run: ./gradlew build
\ No newline at end of file
+ run: ./gradlew build
+
+ ios:
+ runs-on: macos-15
+ timeout-minutes: 35
+
+ env:
+ GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx3g -Dkotlin.daemon.jvmargs=-Xmx3g
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ - name: Cache Kotlin/Native toolchain
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
+ restore-keys: konan-${{ runner.os }}-
+
+ - name: Build and test with Gradle
+ run: ./gradlew :updraft-core:iosSimulatorArm64Test :updraft-ui-compose:iosSimulatorArm64Test :updraft-core:assembleUpdraftCoreXCFramework
+ - name: Build iOS sample app
+ run: xcodebuild -project sample/iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 586d309..b28d2d7 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -4,44 +4,66 @@ on:
push:
branches: [ production ]
+concurrency:
+ group: publish
+ cancel-in-progress: false
+
jobs:
publish:
- runs-on: ubuntu-latest
+ runs-on: macos-15
+ timeout-minutes: 60
+ environment: maven-central
+ permissions:
+ contents: write
+ env:
+ GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx3g -Dkotlin.daemon.jvmargs=-Xmx3g
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: set up JDK 21
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- cache: gradle
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ validate-wrappers: true
+ - name: Cache Kotlin/Native toolchain
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
+ restore-keys: konan-${{ runner.os }}-
+
+ - name: Extract version from gradle.properties
+ id: get_version
+ run: echo "version=$(grep VERSION_NAME updraft-sdk/gradle.properties | cut -d'=' -f2)" >> $GITHUB_OUTPUT
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
+ - name: Fail if version already released
+ run: |
+ if git ls-remote --tags origin "refs/tags/${{ steps.get_version.outputs.version }}" | grep -q .; then
+ echo "::error::Version ${{ steps.get_version.outputs.version }} is already tagged — bump VERSION_NAME before publishing."
+ exit 1
+ fi
+
+ - name: Verify (build + all tests, both platforms)
+ run: ./gradlew build iosSimulatorArm64Test
- name: Publish to Maven Central Portal
- run: ./gradlew :updraft-sdk:publish --no-configuration-cache
+ run: ./gradlew publishAllPublicationsToMavenCentralRepository --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
- - name: Extract version from gradle.properties
- id: get_version
- run: echo "version=$(grep VERSION_NAME updraft-sdk/gradle.properties | cut -d'=' -f2)" >> $GITHUB_OUTPUT
-
- name: Create GitHub Release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
- release_name: ${{ steps.get_version.outputs.version }}
+ name: ${{ steps.get_version.outputs.version }}
body: |
Automated release ${{ steps.get_version.outputs.version }}
-
+
Published to:
- Maven Central
- draft: false
- prerelease: false
diff --git a/README.md b/README.md
index e9bde65..49751f5 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,18 @@

[](https://maven-badges.sml.io/sonatype-central/com.appswithlove.updraft/updraft-sdk)
-[](https://raw.githubusercontent.com/appswithlove/updraft-sdk-ios/master/LICENSE)
+[](LICENSE)
[]([https://twitter.com/GetUpdraft](https://bsky.app/profile/appswithlove.bsky.social))
# Updraft SDK
-Updraft SDK for Android
+Updraft SDK for Android & iOS — built with Kotlin Multiplatform and Compose Multiplatform.
Updraft is a super easy app delivery tool that allows you to simply and quickly distribute your app. It's super useful during beta-testing or if you want to deliver an app without going through the app store review processes. Your users get a link and are guided through the installation process with in a comprehensive web-app. Updraft works with Android and iOS apps and easily integrates with your IDE.
-The SDK adds additional features to apps that are delivered with Updraft: Auto-update for your distributed apps and most importantly the collection of user feedback.
+The SDK adds additional features to apps that are delivered with Updraft: Auto-update for your distributed apps and most importantly the collection of user feedback (shake or screenshot to report, annotate, send).
+
+One codebase serves every kind of app: native Android (Views or Compose), Kotlin/Compose Multiplatform, and pure-Swift iOS apps via an XCFramework. This repository was formerly `updraft-sdk-android`; since 2.0.0 it is the single home of the Updraft SDK for both platforms and supersedes [`updraft-sdk-ios`](https://github.com/appswithlove/updraft-sdk-ios).
Updraft is built by App Agencies [Apps with love](https://appswithlove.com/) and [Moqod](https://moqod.com/). Learn more at [getupdraft.com](https://getupdraft.com/) or follow the latest news on [twitter](https://twitter.com/GetUpdraft).
@@ -18,48 +20,112 @@ Updraft is built by App Agencies [Apps with love](https://appswithlove.com/) and
## Requirements
- minSdkVersion >=23
+- iOS 14.0+ (for `updraft-core` / `updraft-ui-compose` iOS targets)
+
+### Kotlin version compatibility
+
+The SDK is built with Kotlin 2.2 and Compose Multiplatform 1.9. What your project needs depends on how you consume it:
+
+| Your app | Required Kotlin |
+| --- | --- |
+| Java-only Android app | none — Kotlin metadata is not read at runtime |
+| Kotlin Android app | **Kotlin ≥ 2.1** (the Kotlin compiler reads metadata up to one minor version ahead) |
+| KMP / Compose Multiplatform app | **Kotlin ≥ 2.2** (klib consumption requires a matching compiler) |
+| Swift app via `UpdraftCore.xcframework` | none — compiled binary, no Kotlin toolchain involved |
+
+If your Kotlin version is too old, the build fails at compile time (never at runtime) with one of these:
+
+```
+Module 'updraft-core' was compiled with an incompatible version of Kotlin.
+The binary version of its metadata is 2.2.0, expected version is 2.1.0.
+```
+
+```
+KLIB resolver: Could not find "updraft-core" ... compiled with a newer Kotlin compiler
+```
+
+**Fix:** upgrade your project's Kotlin (and for CMP apps, Compose Multiplatform) to the versions above — or stay on Updraft SDK 1.x until you can.
## Installation
-Add the updraft-sdk dependency:
+Updraft 2.0.0 ships as three Kotlin Multiplatform artifacts. Pick the one that matches your app:
+
+| Artifact | Use case |
+| --- | --- |
+| `updraft-sdk` | Android (Views or Compose) apps that want everything out of the box: auto-update, feedback, and the built-in Compose UI, wired up automatically. |
+| `updraft-core` | Logic only, no UI. Common for KMP apps with their own native UI per platform, or size-sensitive Android Views apps that don't want to pull in Compose. |
+| `updraft-core` + `updraft-ui-compose` | Compose Multiplatform (CMP) apps that want the built-in feedback UI but want to host it themselves inside their own Compose tree. |
```kotlin
// libs.versions.toml
[versions]
-updraft-sdk = "1.1.2"
+updraft-sdk = "2.0.0"
[libraries]
-updraft-sdk = { module = "com.appswithlove.updraft:updraft-sdk", version.ref = "updraft-sdk" }
+updraft-sdk = { module = "com.appswithlove.updraft:updraft-sdk", version.ref = "updraft-sdk" }
+updraft-core = { module = "com.appswithlove.updraft:updraft-core", version.ref = "updraft-sdk" }
+updraft-ui-compose = { module = "com.appswithlove.updraft:updraft-ui-compose", version.ref = "updraft-sdk" }
```
```kotlin
-// build.gradle.kts
+// build.gradle.kts (Android all-in-one)
dependencies {
implementation(libs.updraft.sdk)
}
```
+```kotlin
+// build.gradle.kts (KMP commonMain, logic only)
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.updraft.core)
+ }
+ }
+}
+```
+
+```kotlin
+// build.gradle.kts (Compose Multiplatform, own UI hosting)
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.updraft.core)
+ implementation(libs.updraft.ui.compose)
+ }
+ }
+}
+```
+
+`updraft-core` and `updraft-ui-compose` publish `iosArm64`/`iosSimulatorArm64`/`iosX64` targets alongside `androidTarget`, so the same dependency works from a KMP or Compose Multiplatform `commonMain` source set on both platforms. Pure-Swift consumers that don't want Kotlin/Compose tooling use the `UpdraftCore.xcframework` instead — see [Swift integration](#swift-integration) below. `updraft-sdk` (the all-in-one artifact) is Android-only.
+
+`updraft-sdk-ios` is superseded by this SDK once 2.0.0 ships. Archiving that repo and adding a final README banner there are release-time actions, tracked in [`docs/kmp-migration-m1-status.md`](docs/kmp-migration-m1-status.md).
+
## Setup
+`updraft-sdk` requires no manual initialization beyond starting the SDK. Auto-update alerts, the feedback shake-to-report flow, and the feedback screen are all wired up for you via `androidx.startup`.
+
```kotlin
-override fun onCreate() {
- super.onCreate()
- val settings = Settings().apply {
- appKey = APP_KEY
- sdkKey = SDK_KEY
- baseUrl = Settings.BASE_URL_STAGING // Optional base url for updraft
- logLevel = Settings.LOG_LEVEL_DEBUG // Optional log level
- showFeedbackAlert = true // Optional set if should show start alert
- feedbackEnabled = true // force disabling feedback if needed
+class App : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Updraft.start(
+ UpdraftSettings(
+ appKey = APP_KEY,
+ sdkKey = SDK_KEY,
+ // baseUrl defaults to the Updraft production API; override only for self-hosted instances
+ logLevel = LogLevel.Debug, // Optional log level
+ showFeedbackAlert = true, // Optional set if should show start alert
+ feedbackEnabled = true, // force disabling feedback if needed
+ ),
+ )
}
-
- Updraft.initialize(this, settings)
- Updraft.getInstance()?.start()
}
```
+
### Parameters
-- YOUR_SDK_KEY: Your sdk key obtained on [Updraft](https://getupdraft.com)
-- YOUR_APP_KEY: You app key obtained on [Updraft](https://getupdraft.com)
+- appKey: Your app key obtained on [Updraft](https://getupdraft.com)
+- sdkKey: Your sdk key obtained on [Updraft](https://getupdraft.com)
## AutoUpdate
Auto Update functionality can be enabled/disabled on [getupdraft.com](https://getupdraft.com/) in your app edit menu.
@@ -80,23 +146,210 @@ A prompt is shown to the user to inform him of the change of state of the feedba
If enabled, the user is explained how he can give feedback.
User can take a screenshot to give a feedback.
-## Advanced setup: Logging
+### Navigation stack
+
+Each feedback upload includes the app's navigation stack, captured at the moment
+feedback is triggered (e.g. on shake). By default the SDK reports the Android
+activity stack or the iOS view controller chain. Single-activity apps (Compose,
+Navigation Component) should plug in their navigation library via
+`Updraft.navigationStackProvider` — return screen names ordered root to top:
+
+**Jetpack Compose Navigation**
+
+```kotlin
+val navController = rememberNavController()
+LaunchedEffect(navController) {
+ Updraft.navigationStackProvider = {
+ listOfNotNull(navController.currentBackStackEntry?.destination?.route)
+ }
+}
+```
+
+Or track the full back stack yourself with a destination listener:
+
+```kotlin
+val breadcrumbs = mutableListOf()
+navController.addOnDestinationChangedListener { _, destination, _ ->
+ destination.route?.let { route ->
+ breadcrumbs.remove(route)
+ breadcrumbs.add(route)
+ }
+}
+Updraft.navigationStackProvider = { breadcrumbs.toList() }
+```
+
+**Fragments (Navigation Component)**
+
+```kotlin
+Updraft.navigationStackProvider = {
+ navController.currentBackStack.value.mapNotNull { it.destination.label?.toString() }
+}
+```
+
+**SwiftUI (iOS)**
+
+```swift
+UpdraftKt.navigationStackProvider = { ["Home", "Settings"] } // from your router state
+```
+
+Setting the provider to `null` restores the platform default. Updraft's own
+screens are always excluded.
+
+The whole feature is optional: to not send any navigation information at all
+(e.g. for privacy reasons), disable it in the settings — the provider and the
+platform default are then never invoked:
+
+```kotlin
+UpdraftSettings(appKey = ..., sdkKey = ..., sendNavigationStack = false)
+```
+
+## Advanced setup: Logging
To check if data is send properly to Updraft and also see some additional SDK log data in the console, you can set different log levels.
-To change the log level, add the following line before starting the SDK:
+Pass the desired level when constructing `UpdraftSettings`:
```kotlin
-settings.setLogLevel(Settings.LOG_LEVEL_DEBUG);
+UpdraftSettings(
+ appKey = APP_KEY,
+ sdkKey = SDK_KEY,
+ logLevel = LogLevel.Debug,
+)
```
+`LogLevel` is one of `None`, `Error`, `Debug`. Default level: LogLevel.Error => Only warnings and errors will be printed.
+
+## Migrating from 1.x
+
+Version 2.0.0 rebuilds the SDK on Kotlin Multiplatform. `updraft-sdk` is still a drop-in Android dependency and behaves the same at runtime, but the setup API changed. Before migrating, check the [Kotlin version compatibility](#kotlin-version-compatibility) table — projects on Kotlin < 2.1 should stay on 1.x until they upgrade.
-Default level: LOG_LEVEL_ERROR => Only warnings and errors will be printed.
+| 1.x | 2.0.0 |
+| --- | --- |
+| `Settings().apply { appKey = ...; sdkKey = ... }` | `UpdraftSettings(appKey = ..., sdkKey = ..., ...)` — a constructor with named parameters instead of a mutable builder object |
+| `Settings.LOG_LEVEL_DEBUG` / `LOG_LEVEL_ERROR` | `LogLevel.Debug` / `LogLevel.Error` / `LogLevel.None` |
+| `Updraft.initialize(this, settings)` followed by `Updraft.getInstance()?.start()` | `Updraft.start(settings)` — a single call, no `initialize`/`getInstance()` step |
+| `settings.isStoreRelease` | `UpdraftSettings(..., storeRelease = ...)` |
+| `ScreenshotProvider` (custom screenshot capture) | Not supported yet in 2.0.0. Feedback screenshots are captured automatically; a custom-screenshot hook is planned for a follow-up release. |
+
+Other things to know:
+- Context is no longer passed manually — `updraft-sdk` picks it up via `androidx.startup`, so there's no `Application` context argument to `start()`.
+- `updraft-sdk` now pulls in the Compose runtime to render its feedback UI. For a release build with R8/minification this adds roughly 2 MB to a non-Compose app (already-Compose apps see close to zero extra size, since the runtime is shared). If that's not acceptable for a size-sensitive Views app, depend on `updraft-core` directly instead (see below) — it's roughly 200–400 KB and has no UI dependency at all.
+
+## KMP / Compose Multiplatform
+
+### `updraft-core` only (bring your own UI)
+
+Apps with their own native UI per platform (or Views apps avoiding the Compose dependency) depend on `updraft-core` in `commonMain` and hook into the feedback flow directly:
+
+```kotlin
+Updraft.start(UpdraftSettings(appKey = APP_KEY, sdkKey = SDK_KEY))
+
+Updraft.setFeedbackUiPresenter { screenshotPng ->
+ // Show your own feedback UI, e.g. navigate to a screen or present a sheet.
+}
+
+// From your own UI, once the user submits feedback:
+Updraft.sendFeedback(screenshot, type, description, email)
+ .collect { progress -> /* upload progress, 0.0..1.0 */ }
+
+// Once your feedback UI closes (submitted, cancelled, or dismissed), you MUST call this to
+// re-arm shake-to-report detection. Without it, shaking the device again won't trigger feedback.
+Updraft.onFeedbackUiClosed()
+```
+
+Subscribe to `Updraft.events: SharedFlow` to react to update prompts, feedback hints, and errors in your own UI layer.
+
+### `updraft-core` + `updraft-ui-compose` (embed the built-in UI)
+
+Compose Multiplatform apps that want Updraft's built-in feedback UI, but hosted inside their own Compose tree, add `updraft-ui-compose` and use `UpdraftEventHost` plus `FeedbackScreen`:
+
+```kotlin
+Updraft.start(UpdraftSettings(appKey = APP_KEY, sdkKey = SDK_KEY))
+
+@Composable
+fun App() {
+ // Renders update/feedback-hint dialogs as an overlay; call anywhere in your Compose tree.
+ UpdraftEventHost(
+ events = Updraft.events,
+ onFeedbackRequested = {
+ // Obtain the screenshot captured for this request, then navigate to your feedback screen/route.
+ val screenshotPng = Updraft.takePendingScreenshot()
+ navigateToFeedback(screenshotPng)
+ },
+ )
+
+ // your app content
+}
+
+@Composable
+fun FeedbackRoute(screenshotPng: ByteArray?, onClose: () -> Unit) {
+ FeedbackScreen(
+ screenshotPng = screenshotPng,
+ onClose = {
+ onClose()
+ // Re-arms shake-to-report detection.
+ Updraft.onFeedbackUiClosed()
+ },
+ )
+}
+```
+
+### iOS
+
+Both setups above work unchanged in `iosMain` — `Updraft.start`, `Updraft.events`, `Updraft.setFeedbackUiPresenter`, `Updraft.sendFeedback`, etc. are the same `commonMain` API on iOS as on Android. There's no iOS-specific initialization step; call `Updraft.start(...)` once from your shared Kotlin entry point (e.g. the function your `iOSApp.swift` calls on launch).
+
+Compose Multiplatform apps that added `updraft-ui-compose` get the built-in feedback UI wired up for free on iOS via `UpdraftIos.autoWire()` — it presents the update/feedback dialogs and the feedback screen on top of the current key window, no `UpdraftEventHost` needed:
+
+```kotlin
+// commonMain or iosMain, called once from your app's iOS entry point
+fun startUpdraft() {
+ Updraft.start(UpdraftSettings(appKey = APP_KEY, sdkKey = SDK_KEY))
+ UpdraftIos.autoWire()
+}
+```
+
+Shake detection uses `CMMotionManager` (accelerometer), which needs no `Info.plist` permission entry.
+
+### Swift integration
+
+`updraft-core` builds an `UpdraftCore.xcframework` for pure-Swift consumers that don't want to pull in Kotlin/Compose tooling:
+
+```
+./gradlew :updraft-core:assembleUpdraftCoreXCFramework
+```
+
+The framework is written to `updraft-core/build/XCFrameworks/release/UpdraftCore.xcframework`. Drag it into an Xcode project or wrap it as an SPM binary target; how it's distributed (e.g. published alongside a release, hosted as a zip) is a release-time decision, out of scope for M2.
+
+`UpdraftCore.xcframework` exposes the same `commonMain` API as above — `Updraft`, `UpdraftSettings`, `Updraft.events`, etc. — callable from Swift:
+
+```swift
+import UpdraftCore
+
+Updraft.shared.start(
+ settings: UpdraftSettings(
+ appKey: APP_KEY,
+ sdkKey: SDK_KEY,
+ baseUrl: UpdraftSettings.companion.BASE_URL_PROD,
+ logLevel: .error,
+ showFeedbackAlert: true,
+ feedbackEnabled: true,
+ storeRelease: false
+ )
+)
+```
+
+Kotlin default parameter values aren't exposed to the generated Objective-C/Swift header, so every `UpdraftSettings` argument must be passed explicitly from Swift (unlike Kotlin callers, which can rely on the defaults shown in [Setup](#setup)).
+
+Since this framework only wraps `updraft-core`, it does not include the built-in Compose feedback UI (`updraft-ui-compose`, incl. `UpdraftFeedbackViewController`) — a pure-Swift, non-Compose app builds its own feedback screen and drives it via `Updraft.setFeedbackUiPresenter` / `Updraft.sendFeedback`, the same way an `updraft-core`-only Android/KMP app does. `UpdraftFeedbackViewController` and `UpdraftIos.autoWire()` are only reachable from a Kotlin/Compose Multiplatform app's own shared Kotlin code, as in the [`iOS` section](#ios) above.
## Local Development
In order to locally develop this plugin, the sample project can be used for easy testing. Additionally, the gradle task `publishToMavenLocal` allows to install the current version to Maven Local.
+### Strings / Loco
+
+UI strings live in `updraft-ui-compose/src/commonMain/composeResources/values*/strings.xml` (en/de) and are shared by Android and iOS. They are managed on [Loco](https://localise.biz); `./gradlew :updraft-ui-compose:updateLoco` re-fetches them into the compose-resources directory (same `strings.xml` format as classic Android resources). The task needs `updraft.locoApiKey=` in `local.properties` and **overwrites local edits** — string changes must be made in Loco first, then pulled. Adding a new key: add it to the Loco project (en + de), run `updateLoco`, reference it via `Res.string.`.
+
## Release
-In order to release a new version of this plugin, create a new release on GitHub and the [pipeline](.github/workflows/publish.yml) will automatically publish the new version to Maven Central.
+Pushing to the `production` branch triggers the [publish workflow](.github/workflows/publish.yml), which publishes `updraft-core`, `updraft-ui-compose`, and `updraft-sdk` to Maven Central.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
deleted file mode 100644
index 61f7004..0000000
--- a/app/build.gradle.kts
+++ /dev/null
@@ -1,76 +0,0 @@
-import org.gradle.kotlin.dsl.withType
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
-
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.updraft)
-}
-
-android {
- namespace = "com.appswithlove.updraftsdk"
- compileSdk = 36
-
- defaultConfig {
- applicationId = "com.appswithlove.updraftsdk"
- minSdk = 23
- targetSdk = 36
- versionCode = 5
- versionName = "1.4"
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- }
-
- signingConfigs {
- create("release") {
- storeFile = file("updraft_test.jks")
- storePassword = "appswithlove"
- keyAlias = "release"
- keyPassword = "appswithlove"
- }
- }
-
- buildTypes {
- release {
- signingConfig = signingConfigs.getByName("release")
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
- }
-
- tasks.withType().configureEach {
- compilerOptions {
- jvmTarget.set(JvmTarget.JVM_11)
- freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
- }
- }
-
- buildFeatures {
- viewBinding = true
- buildConfig = true
- }
-}
-
-val updraftUploadUrl: String = findProperty("updraft_uploadUrl") as? String ?: ""
-updraft {
- urls = mapOf("Release" to listOf(updraftUploadUrl))
-}
-
-dependencies {
- implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
- implementation(project(":updraft-sdk"))
- implementation(libs.appcompat)
- implementation(libs.material)
- implementation(libs.constraintlayout)
- implementation(libs.core.ktx)
- implementation(libs.lifecycle.viewmodel.ktx)
- implementation(libs.kotlin.stdlib.jdk7)
-}
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
deleted file mode 100644
index ca1666b..0000000
Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ
diff --git a/app/src/main/java/com/appswithlove/updraftsdk/App.kt b/app/src/main/java/com/appswithlove/updraftsdk/App.kt
deleted file mode 100644
index ffd4b23..0000000
--- a/app/src/main/java/com/appswithlove/updraftsdk/App.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.appswithlove.updraftsdk
-
-import android.app.Application
-import com.appswithlove.updraft.Settings
-import com.appswithlove.updraft.Updraft
-
-/**
- * Created by satori on 3/27/18.
- */
-class App : Application() {
- override fun onCreate() {
- super.onCreate()
- val settings = Settings().apply {
- appKey = "" //APP_KEY
- sdkKey = "" // SDK_KEY
- isStoreRelease = false
- logLevel = Settings.LOG_LEVEL_DEBUG
- showFeedbackAlert = false
- feedbackEnabled = true
- }
-
- Updraft.initialize(this, settings)
- Updraft.getInstance()?.start()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/appswithlove/updraftsdk/MainActivity.kt b/app/src/main/java/com/appswithlove/updraftsdk/MainActivity.kt
deleted file mode 100644
index acbfd86..0000000
--- a/app/src/main/java/com/appswithlove/updraftsdk/MainActivity.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.appswithlove.updraftsdk
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.WindowInsetsControllerCompat
-import com.appswithlove.updraftsdk.databinding.ActivityMainBinding
-
-class MainActivity : AppCompatActivity() {
-
- private var _binding: ActivityMainBinding? = null
- private val binding get() = _binding!!
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- _binding = ActivityMainBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true
- WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = true
- }
-
- override fun onStart() {
- super.onStart()
-
- binding.button.setOnClickListener {
- startActivity(Intent(this, MainActivity::class.java))
- }
- }
-}
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 747664f..0000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index 8bd5a4d..0000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- #454545
- #232323
- #DAC64E
-
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index d0249b3..0000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
diff --git a/build.gradle.kts b/build.gradle.kts
index 083d6d9..3b1e93c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.dokka) apply false
alias(libs.plugins.updraft) apply false
diff --git a/docs/cmp-migration-overview.md b/docs/cmp-migration-overview.md
new file mode 100644
index 0000000..00ab32f
--- /dev/null
+++ b/docs/cmp-migration-overview.md
@@ -0,0 +1,129 @@
+# Updraft SDK 2.0 — Migration Overview
+
+One-page map of what changed between the legacy Android SDK (1.x) and the
+Kotlin/Compose Multiplatform SDK (2.0). Companion to
+[`kmp-migration-m1-status.md`](kmp-migration-m1-status.md) (detailed status &
+open items).
+
+## Big picture
+
+```
+BEFORE (1.x) AFTER (2.0)
+───────────────────────────── ─────────────────────────────────────────
+updraft-sdk-android (Android) updraft-sdk (this repo, rebranded)
+updraft-sdk-ios (Swift) ├── updraft-core KMP (android + ios)
+ ├── updraft-ui-compose CMP (android + ios)
+2 codebases, 2 feature sets └── updraft-sdk Android wrapper
+ 1 codebase, identical features both platforms
+ + UpdraftCore.xcframework for pure-Swift apps
+```
+
+- ~3500 lines of legacy Views/Fragments/Retrofit code deleted (commit `dd72439`).
+- `updraft-sdk-ios` superseded; archived at release.
+- Same Maven coordinates (`com.appswithlove.updraft:updraft-sdk:2.0.0`), plus two
+ new artifacts with iOS klibs (`iosArm64`, `iosSimulatorArm64`, `iosX64`).
+
+## Library replacements
+
+| Concern | Before (1.x, Android) | After (2.0, common code) |
+|---|---|---|
+| HTTP | Retrofit 2 + OkHttp + logging-interceptor | **Ktor client 3.3** (OkHttp engine on Android, Darwin on iOS) |
+| Async | RxJava 2 (+ rxjava2 adapter) | **kotlinx.coroutines 1.10** — `Flow` for upload progress, `SharedFlow` for events |
+| JSON | kotlinx.serialization via Retrofit converter | **kotlinx.serialization** via Ktor content-negotiation |
+| UI framework | XML layouts, Fragments, AppCompat, Material Components | **Compose Multiplatform** (material3) — one UI for Android + iOS |
+| Drawing/annotation | FreeDrawView port (`com.rm.freedrawview`) + ink lib | **Custom `DrawingCanvas` + `DrawingController`** (~100 lines: path list, undo/redo stacks, Compose `Canvas`) |
+| Lifecycle | lifecycle-extensions, ViewModel | plain state holders (`FeedbackScreenState`), no ViewModel dependency |
+| Persistence | SharedPreferences | `KeyValueStore` expect/actual → SharedPreferences (Android) / NSUserDefaults (iOS) |
+| Auto-init | androidx.startup | androidx.startup kept (Android); `UpdraftIos.autoWire()` (iOS) |
+| Strings | Android resources + Loco | compose-resources, en/de, shared across platforms |
+
+## Architecture: shared core + thin platform seams
+
+Everything lives in `commonMain`; platforms only implement small `expect/actual` seams:
+
+| Seam (`expect`) | Android `actual` | iOS `actual` |
+|---|---|---|
+| `createShakeDetector` | `SensorManager` accelerometer (g-force math shared) | `UIWindow.motionEnded` runtime hook (simulator + device) + `CMMotionManager` + screenshot notification (legacy iOS trigger) |
+| `createScreenshotGrabber` | root view → `Canvas`/`Bitmap` → PNG | key `UIWindow` → `UIGraphicsImageRenderer` → `UIImagePNGRepresentation` |
+| `createAppForegroundObserver` | `ProcessLifecycleOwner`-style callbacks | `UIApplicationDidBecomeActive` / `DidEnterBackground` notifications |
+| `openUrl` | `Intent.ACTION_VIEW` | `UIApplication.openURL` |
+| `createKeyValueStore` | SharedPreferences | NSUserDefaults |
+| `currentAppInfo` | PackageManager + `ANDROID_ID` | NSBundle (`CFBundleVersion`) + `identifierForVendor` |
+| `currentNavigationStack` | activity stack (`ActivityLifecycleCallbacks`) | view-controller chain walk (nav/tab aware) |
+| `decodePng` (ui-compose) | `BitmapFactory` | Skia image codec |
+
+Event flow (identical on both platforms):
+
+```
+shake / screenshot / button
+ │
+ ▼
+Updraft (commonMain) ── captures screenshot + navigation stack
+ │
+ ▼ UpdraftEvent via SharedFlow
+ │
+ ┌────┴─────────────────────────────┐
+ │ Android: androidx.startup │ iOS: UpdraftIos.autoWire()
+ │ auto-wire → UpdraftFeedback/ │ → presents ComposeUIViewController
+ │ OverlayActivity (Compose host) │ on topmost view controller
+ └────┬─────────────────────────────┘
+ ▼
+FeedbackScreen (commonMain CMP) → annotate → form → send
+ │
+ ▼
+Ktor multipart POST feedback-mobile/ (progress via onUpload → callbackFlow)
+```
+
+## Compose feedback UI — how the design was done
+
+- Legacy XML layouts, colors, styles and PNG assets were **recovered from git
+ history** (`git show dd72439^:updraft-sdk/src/main/res/...`) and rebuilt
+ pixel-faithfully in Compose: charcoal `#38383C` chrome, Updraft logo header,
+ "Feedback geben" title, close X, 4 color swatches (black/white/`#F6B33E`
+ yellow/`#EB6764` red) with blue `#4A90E2` selection halo, dashed-circle
+ undo button, yellow-italic "Draw something here…" overlay, white box form
+ fields, yellow action buttons.
+- Assets (`updraft_logo_white.png`, exit icons) extracted from history into
+ compose-resources; dashed circle drawn with `PathEffect.dashPathEffect`.
+- Screen is inset via `WindowInsets.safeDrawing`; dark background fills behind
+ the system bars.
+- The legacy "eraser" turned out to be undo-last-stroke — replicated as such.
+- Annotation strokes are recorded in canvas coordinates and re-projected onto
+ the full-resolution screenshot bitmap at send time (`renderAnnotated`), so
+ drawings align on the dashboard regardless of screen density.
+
+## Screenshot → upload pipeline
+
+1. Trigger fires → platform `ScreenshotGrabber` captures the **host app** as PNG bytes (before any Updraft UI exists).
+2. `FeedbackScreen` shows the PNG with a `DrawingCanvas` overlay; paths + color + 4dp stroke tracked by `DrawingController` (undo/redo).
+3. On send: paths rendered onto the bitmap → PNG re-encoded → Ktor `MultiPartFormDataContent` with `image`, `app_key`, `sdk_key`, `tag`, `description`, `email`, `build_version`, `system_version`, `device_name`, `device_uudid` (sic — server field), `navigation_stack`.
+4. Upload progress: Ktor `onUpload` → `callbackFlow` → progress bar; HTTP errors fail the flow (`expectSuccess = true`).
+
+## Public API (2.0)
+
+```kotlin
+Updraft.start(UpdraftSettings(appKey, sdkKey,
+ /* baseUrl, logLevel, showFeedbackAlert, feedbackEnabled, storeRelease, sendNavigationStack */))
+Updraft.events: SharedFlow // ShowFeedbackHint, UpdateAvailable, …
+Updraft.checkForUpdate(); Updraft.showFeedback()
+Updraft.navigationStackProvider = { listOf("Home", "Detail") } // plug in any nav library
+```
+
+Breaking changes vs 1.x are listed in the README migration table
+(`Settings` → `UpdraftSettings`, `initialize()+getInstance()` → `start()`).
+
+## Found & fixed during device verification (2026-07-22)
+
+1. `:updraft-sdk` missing Compose compiler plugin → `NoSuchMethodError` crash on any dialog.
+2. Sample lacked the `:updraft-sdk` dependency → events silently dropped on Android.
+3. `BASE_URL_STAGING` (u2.mqd.me) dead → removed; sample & default now production.
+4. `device_uuid` vs server's `device_uudid` (legacy typo, load-bearing) → UUID now arrives.
+5. Navigation stack: never existed on Android → implemented + pluggable provider + opt-out.
+6. iOS shake: responder-chain event, not accelerometer → `UIWindow.motionEnded` hook (`staticCFunction` IMP; Kotlin block bridging would segfault on the `NSInteger` arg).
+7. Feedback UI restyled to legacy design (was plain default material3).
+
+## Repo rebrand
+
+Formerly `updraft-sdk-android` → **`updraft-sdk`**, single home for both
+platforms. Code-side done (rootProject name, POM URLs, README); GitHub rename +
+archiving `updraft-sdk-ios` are manual release steps.
diff --git a/docs/kmp-migration-m1-status.md b/docs/kmp-migration-m1-status.md
new file mode 100644
index 0000000..cca971b
--- /dev/null
+++ b/docs/kmp-migration-m1-status.md
@@ -0,0 +1,133 @@
+# KMP Migration M1 — Status & Follow-ups
+
+Date: 2026-07-16 · Branch: `feature/kmp-migration-m1` (21 commits, unmerged) · Issue: [#13](https://github.com/appswithlove/updraft-sdk-android/issues/13)
+Spec: [2026-07-16-kmp-migration-design.md](superpowers/specs/2026-07-16-kmp-migration-design.md) · Plan: [2026-07-16-kmp-migration-m1.md](superpowers/plans/2026-07-16-kmp-migration-m1.md)
+
+## What was built
+
+| Module | Coords | Content |
+|---|---|---|
+| `:updraft-core` | `com.appswithlove.updraft:updraft-core:2.0.0` | KMP (androidTarget in M1). Ktor client, coroutines/Flow, `Updraft.start(UpdraftSettings)`, `Updraft.events: SharedFlow`, interactors, expect/actual seams (KeyValueStore, AppInfo, shake, screenshot, url, foreground observer). Replaces Retrofit + RxJava2. |
+| `:updraft-ui-compose` | `com.appswithlove.updraft:updraft-ui-compose:2.0.0` | Compose Multiplatform feedback UI: `FeedbackScreen` (annotate + form), `DrawingCanvas` (FreeDrawView port), dialogs, `UpdraftEventHost`. Strings en/de via compose-resources. |
+| `:updraft-sdk` | `com.appswithlove.updraft:updraft-sdk:2.0.0` (coords unchanged) | Thin Android wrapper: 2 host activities + androidx.startup auto-wire. Entire legacy Views/Fragments/FreeDrawView/Retrofit stack deleted (~3500 lines). |
+
+Breaking change: 2.0.0. `Settings` → `UpdraftSettings`, `Updraft.initialize(context, settings)` + `getInstance()?.start()` → `Updraft.start(settings)`. Migration table in README.
+
+Full build + all unit tests green. Final adversarial whole-branch review: READY TO MERGE. Each task individually spec- and quality-reviewed with fix loops (ledger: `.superpowers/sdd/progress.md`, gitignored).
+
+## Release checklist (merge ≠ release — these BLOCK a 2.0.0 release)
+
+- [x] **Fix `.github/workflows/publish.yml`** — DONE in M2: now publishes all three modules from a macOS runner. (Was: published only `:updraft-sdk`, whose POM references the other two via `api(project(...))`, so a one-module publish would ship a broken 2.0.0.)
+- [ ] **Manual device verification** (needs real APP_KEY/SDK_KEY — currently empty strings in `app/App.kt`, real keys stripped from repo in 2024):
+ 1. Launch → feedback hint dialog on first start
+ 2. Shake → annotate screen; **draw near screen corners**; undo/redo; OK → form; select type; send → progress → closes
+ 3. Inspect uploaded image on staging dashboard — verify annotation positions align (coordinate mapping was the critical fix; stroke *width* scaling consciously skipped, see follow-ups)
+ 4. Rotate mid-form — screenshot must survive, no stacked activities
+ 5. Lower build number installed → update dialog; "Open" opens browser
+ 6. Toggle feedback-enabled on dashboard → disabled/how-to dialog on next foreground
+ 7. Shake twice in a row (second after closing feedback UI) — must work both times
+- [x] README `Release` section reconciled with the `production`-branch push trigger — DONE in M2.
+
+## Follow-ups (non-blocking, ordered roughly by impact)
+
+1. **Stroke width not rescaled in `renderAnnotated`** — positions map correctly to bitmap space, but a 12px screen stroke stays 12px on the full-res bitmap → annotations look thinner in uploads. Fix: divide `strokeWidthPx` by the fit scale.
+2. **Rotation loses form/drawing state** — `remember` not `rememberSaveable` in `FeedbackScreen`; typed text + drawn paths gone, in-flight upload cancelled silently. Screenshot itself survives (fixed).
+3. **`UpdraftOverlayActivity.pendingEvent` static race** — two dialog events in quick succession can overwrite before first `onCreate` consumes; one dialog dropped. Proper fix: intent extras instead of static holder.
+4. **Double feedback hint on fresh install** — sync `onForeground` hint + async state-transition hint can both fire in one cycle (legacy-inherited); dedupe via `feedbackHintShown` on the async path.
+5. **`updateAlertShown` race** — two rapid foregrounds → concurrent update checks both pass the once-guard → double dialog. Window = network RTT.
+6. **Late `Updraft.start()` drops events** — if called after first foreground (not in `Application.onCreate`), the auto-wire collector isn't subscribed yet (`replay=0`) and the hint is permanently lost. Document the `Application.onCreate` requirement prominently, or add replay.
+7. **`decodePng` NPE on invalid bytes** — `BitmapFactory.decodeByteArray` returns null on garbage; `FeedbackScreen(screenshotPng, ...)` is public API. Add null handling.
+8. **Spec's `FeedbackSent` event unimplemented** — consumers can't observe successful sends via `Updraft.events`.
+9. **Empty-screenshot upload** — `screenshotPng == null` path uploads `ByteArray(0)` as image part; server acceptance untested.
+10. **Unbounded 100ms poll** in `UpdraftAndroid.autoWire()` if `start()` never called. Cosmetic.
+11. **Stale Retrofit keep rule** in `updraft-sdk/proguard-rules.pro` (inert, minify off).
+12. **Compose UI tests missing** — spec called for Robolectric-based UI tests (form validation, drawing); only state/controller unit tests exist. An annotate→render UI test would have caught the coordinate-mapping bug.
+13. **Feedback hint copy** — form labels localized (en/de) in final fix wave; verify German copy with a native reader before release.
+
+## Deferred to M2 (per spec)
+
+- iOS targets + `iosMain` actuals (motionEnded shake, UIWindow screenshot, itms-services install, NSUserDefaults)
+- XCFramework export (SPM/CocoaPods), iosApp sample, deprecate [updraft-sdk-ios](https://github.com/appswithlove/updraft-sdk-ios)
+- `app/` → `sample/` rename (CMP structure with androidApp + iosApp)
+
+## Post-migration reminder (from spec)
+
+- ~~Revisit options for Views-based Android apps~~ — **decided 2026-07-22: Compose ships for Views apps too.** `updraft-sdk` stays Compose-based regardless of the host app's UI stack; no `updraft-ui-views` artifact will be built. Rationale: one UI codebase across four app types (Android Views, Android Compose, CMP, iOS) is the point of the migration; the ~2 MB cost is acceptable (R8 strips unused parts, and most active apps already ship Compose); host activities isolate Compose from the app's own view hierarchy, so nothing leaks into it. Escape hatch for size-sensitive apps stays: depend on `updraft-core` only and bring your own feedback UI (documented in the README).
+
+## Dev environment note
+
+Git commits are SSH-signed via 1Password; the signing agent intermittently fails (`1Password: failed to fill whole buffer`). Unlock/approve 1Password and retry — no config change needed.
+
+## M2 status (2026-07-21)
+
+Branch: `feature/kmp-migration-m2` (unmerged).
+
+### What M2 delivered
+
+- **iOS targets + actuals** on `:updraft-core` (`iosArm64`, `iosSimulatorArm64`, `iosX64`): `IosShakeDetector` (`CMMotionManager` accelerometer, no permission entry needed), `IosScreenshotGrabber` (key-window render to PNG), `IosForegroundObserver` (`UIApplicationDidBecomeActive`/`DidEnterBackground`), `KeyValueStore`/`AppInfo` actuals.
+- **`:updraft-ui-compose` iOS target**: same Compose feedback UI (`FeedbackScreen`, dialogs) compiled for iOS via Compose Multiplatform; `com.appswithlove.updraft.ui.ios.UpdraftIos.autoWire()` auto-presents dialogs/feedback screen on the topmost view controller, and `UpdraftFeedbackViewController(screenshotPng, onClose)` is exposed for manual hosting.
+- **Sample**: `app/` renamed to `sample/` — `sample/composeApp` (shared Kotlin, `MainViewController()` + `startUpdraft()`) and `sample/iosApp` (xcodegen `project.yml`, `iOSApp.swift` calling `MainViewControllerKt.startUpdraft()`, iOS 14.0 deployment target).
+- **XCFramework**: `:updraft-core:assembleUpdraftCoreXCFramework` builds `UpdraftCore.xcframework` (logic-only; does not include `updraft-ui-compose`).
+- **CI**: `.github/workflows/build.yml` adds an `ios` job (macos-15) running both modules' `iosSimulatorArm64Test`, the XCFramework assemble task, and an `xcodebuild` of `sample/iosApp`. Triggers on `main` and `feature/**` branches. **Verified green on CI** (both android + ios jobs) on branch HEAD.
+- README: real iOS setup replacing the "M2, not yet available" placeholder — Installation, iOS quickstart, and extended Swift integration sections; `updraft-sdk-ios` superseded note added.
+
+### Verification findings (fixed on-branch)
+
+- **Runner bumped macos-14 → macos-15**: the xcodegen-produced `project.pbxproj` uses `objectVersion 77` (Xcode 16 format); macos-14 ships Xcode 15 and fails with `Unable to read project 'iosApp.xcodeproj'` (exit 74). Fixed in both `build.yml` and `publish.yml`.
+- **`CADisableMinimumFrameDurationOnPhone` added to `sample/iosApp/iosApp/Info.plist`**: without it, Compose Multiplatform's `PlistSanityCheck` throws `IllegalStateException` and the sample **crashes on launch**. Any CMP-hosting iOS app (including consumer apps) needs this key — documented as a requirement.
+- **End-to-end confirmed**: sample launched on an iPhone 16 simulator; `startUpdraft()` → iOS foreground observer → controller event → `UpdraftIos.autoWire()` → `ComposeUIViewController` renders the CMP feedback hint dialog ("Feedback geben"). Full shared chain works on iOS.
+
+### Remains release-time (blocks a 2.0.0 release, not this branch)
+
+- [ ] Run the publish workflow once (`.github/workflows/publish.yml`, all three modules, macos-15, triggered by push to `production`) and verify all three land on Maven Central.
+- [ ] Manual device verification on **both** platforms with real `APP_KEY`/`SDK_KEY` (still empty strings in the sample) — Android checklist above, plus iOS: launch → hint dialog; shake → annotate → form → send; rotate mid-form; update dialog against staging; feedback-enabled toggle.
+- [ ] Archive [`updraft-sdk-ios`](https://github.com/appswithlove/updraft-sdk-ios) and add its final README deprecation banner.
+
+### M2-specific follow-ups (non-blocking, ordered roughly by impact)
+
+1. **iOS shake has no auto-pause on background** — `Updraft.start`'s `createAppForegroundObserver(... onBackground = { })` is a no-op; unlike a real pause, `IosShakeDetector` keeps listening to the accelerometer while backgrounded (Android's equivalent stops the sensor). Low impact (CoreMotion is cheap while backgrounded, and `presentViewController` would just no-op with no key window), but inconsistent with Android behavior.
+2. **`UpdraftIos.presentDialog`/`presentFeedback` can silently drop events** — `topmostViewController() ?: return` and the `presentViewController` call have no retry/queueing; an event that arrives while `rootViewController` is nil (e.g. very early launch) or while another `presentViewController` transition is still in flight is dropped rather than deferred.
+3. **`keyWindow()` helper duplicated** — near-identical `UIApplication.sharedApplication.windows.filterIsInstance().firstOrNull { it.isKeyWindow() }` exists in both `updraft-core`'s `IosScreenshotGrabber` (`Platform.ios.kt`) and `updraft-ui-compose`'s `UpdraftIos.kt`. No shared internal utility between the two iOS source sets yet.
+4. **Sample `UpdraftSettings` construction duplicated** — Android (`sample/composeApp` androidMain/App) and iOS (`MainViewController.kt#startUpdraft`) each hardcode their own `UpdraftSettings(...)` call; no shared `commonMain` config object.
+5. **CI runner memory tuning** — `ios` job on `macos-15` (7 GB total) overrides Gradle/Kotlin daemon heap to `-Xmx3g` each (`GRADLE_OPTS` in `build.yml`), vs. defaults elsewhere; worth revisiting if iOS builds get memory-constrained as the project grows.
+6. **`sample/iosApp/iosApp/Info.plist` has a stale `UIRequiredDeviceCapabilities: [armv7]`** — meaningless for an arm64-only/simulator build target (armv7 was 32-bit); harmless but should be dropped in a cleanup pass.
+
+## Device verification round (2026-07-22)
+
+Manual verification with real keys (Pixel 8 Pro + iPhone 16 simulator, production API). Keys live in `local.properties` (gitignored): `updraft.appKey.android`, `updraft.appKey.ios`, `updraft.sdkKey` — generated into `SampleKeys.kt` at build time.
+
+### Release-blocking bugs found & fixed
+
+1. **`:updraft-sdk` crashed on any dialog** — module never applied the Compose compiler plugin; `setContent` lambdas compiled as plain `Function0` → `NoSuchMethodError` at runtime. Compiles fine, only explodes on device — exactly what the manual-verification blocker existed for.
+2. **Sample had no Android event collector** — `sample/composeApp` didn't depend on `:updraft-sdk`, so hint/feedback/update dialogs were silently dropped on Android (M2's `app/`→`sample/` rewrite lost the dep).
+3. **`BASE_URL_STAGING` (u2.mqd.me) is dead** — host unreachable; every network call failed. Sample now uses the prod default; the constant is removed from `UpdraftSettings` (baseUrl stays overridable).
+4. **`device_uuid` never arrived on the dashboard** — server field is `device_uudid` (legacy iOS SDK typo, load-bearing). Fixed.
+5. **iOS shake never fired** — shake is a responder-chain `motionEnded:` event, not accelerometer data; `CMMotionManager` can't see it on the simulator and nothing forwarded it. SDK now swizzles `UIWindow.motionEnded:withEvent:` (IMP is a `staticCFunction` — Kotlin block bridging marshals the `NSInteger` arg as an object and segfaults). Screenshot trigger (legacy iOS behavior) also added; fires on hardware only.
+
+### Features added during verification
+
+- **Feedback UI restyled to the legacy design** (charcoal chrome, Updraft logo header, color swatches + halo, dashed undo, draw-hint overlay, white form fields, yellow buttons; safeDrawing insets). Assets recovered from `dd72439^`.
+- **Navigation stack now sent with feedback**: platform default (activity stack / VC chain, Updraft screens excluded) + `Updraft.navigationStackProvider` for single-activity apps (Compose Navigation etc.); opt out via `UpdraftSettings(sendNavigationStack = false)`. README has per-library examples.
+
+### Verified end-to-end
+
+- Android (Pixel 8 Pro, prod API): hint dialog, shake → annotate (draw/colors/undo) → form → send ✅ received on dashboard incl. UUID + navigation stack.
+- iOS (iPhone 16 simulator): hint dialog, shake (⌃⌘Z) → feedback UI, manual button flow ✅.
+- `publishToMavenLocal` (signing skipped locally): all 3 modules + iOS klibs land with correct POM chain; fresh consumer app resolves `updraft-sdk:2.0.0` from mavenLocal and compiles ✅. Consumers need `android.useAndroidX=true` (document in README).
+
+### Update feature verification (2026-07-22, later)
+
+- **Android: fully verified E2E** — versionCode-6 release APK uploaded via cURL (`app_upload`), server reports `is_new_version=true` for the installed v5, update dialog shown, "Open" lands on the Updraft install page. Both builds release-signed, so install-over works.
+- **iOS: verified by construction, not E2E.** Updraft's server rejects unsigned IPAs (HTTP 500), and no Apple signing team was available. Audit against the legacy `updraft-sdk-ios`: request/response models field-identical for `check_last_version/` + `get_last_version/`, same version source (CFBundleVersion), same flow order, same `UIApplication.open` call; endpoints empirically proven via the Android E2E on the same account; `UpdraftIos.autoWire` handles `UpdateAvailable`, and dialog presentation is proven live (hint dialog). Remaining untested surface is server-side signed-IPA handling + itms-services install — do once with a provisioned Ad Hoc build at release time.
+
+### Still open before 2.0.0
+
+- Real-iPhone pass: hardware shake, screenshot trigger, send from iOS (simulator send still unverified end-to-end on dashboard), and one signed-IPA update-dialog run (see above).
+- Publish workflow run + Maven Central verification; archive `updraft-sdk-ios` (unchanged from above).
+- Follow-up: SDK logging is a silent no-op on Android (Ktor default logger → SLF4J with no provider).
+
+## Deferred to M3 (per spec, unchanged from M1)
+
+- ~~Fresh-repo/rename decision~~ — **decided 2026-07-22**: keep this repository and rebrand it as the multiplatform SDK. Code-side prep done (rootProject.name, POM URLs/name, README repositioning → `updraft-sdk`). Manual GitHub steps remain: rename the repo `updraft-sdk-android` → `updraft-sdk` (Settings → General; old URLs redirect), update repo description/topics, archive `updraft-sdk-ios` at release.
+- SPM packaging polish, further platform targets.
+- **AGP 9 / Gradle 9 upgrade** — tracked in [#16](https://github.com/appswithlove/updraft-sdk-android/issues/16) (decided 2026-07-22: do it, but as its own milestone — not in M2, which was device-verified on AGP 8.13): built-in Kotlin migration for `:updraft-sdk`, migration of the KMP modules to the new `com.android.kotlin.multiplatform.library` target plugin (affects publishing variants + compose-resources), CMP/vanniktech/updraft-plugin AGP 9 compatibility checks, and loco plugin 1.2.0 (`locoFetch`/`locoPush`). Requires a re-verification round on device afterwards.
diff --git a/docs/superpowers/plans/2026-07-16-kmp-migration-m1.md b/docs/superpowers/plans/2026-07-16-kmp-migration-m1.md
new file mode 100644
index 0000000..8de7e8a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-16-kmp-migration-m1.md
@@ -0,0 +1,2930 @@
+# Updraft SDK KMP Migration — M1 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Restructure the repo into `updraft-core` (KMP, Android target) + `updraft-ui-compose` (CMP) + rewrapped `updraft-sdk` 2.0.0, replacing Retrofit/RxJava2 with Ktor/coroutines and Views UI with Compose, with full Android feature parity.
+
+**Architecture:** Layered artifacts per spec `docs/superpowers/specs/2026-07-16-kmp-migration-design.md`. Core holds all logic in `commonMain` behind expect/actual platform seams; ui-compose holds the feedback/update UI; updraft-sdk is a thin Android wrapper keeping existing Maven coords.
+
+**Tech Stack:** Kotlin 2.2.21 Multiplatform, Compose Multiplatform 1.9.x, Ktor 3.x, kotlinx.serialization, kotlinx.coroutines, kotlin-test + Turbine, androidx.startup.
+
+## Global Constraints
+
+- Kotlin `2.2.21`, AGP `8.13.0`, compileSdk `36`, minSdk `23`, JVM target `11` (from existing catalog — do not lower/raise).
+- Package root stays `com.appswithlove.updraft`.
+- `updraft-sdk` Maven coords stay `com.appswithlove.updraft:updraft-sdk`; new artifacts `updraft-core`, `updraft-ui-compose`. Version `2.0.0` everywhere.
+- API endpoints and field names verbatim: `check_last_version/`, `get_last_version/`, `feedback-mobile/`, `feedback-mobile-enabled/`; multipart fields `image`, `app_key`, `sdk_key`, `tag`, `description`, `email`, `build_version`, `system_version`, `device_name`, `device_uuid`.
+- Base URLs verbatim: prod `https://app.getupdraft.com/api/`, staging `https://u2.mqd.me/api/`.
+- Feedback tag strings verbatim: `design`, `feedback`, `bug`.
+- SharedPreferences file/key names verbatim (`feedback_enabled_storage`, `is_feedback_enabled_property`) — existing installs must not re-show alerts.
+- Shake constants verbatim: threshold `2.7f` g, slop `500` ms, reset `3000` ms.
+- No `Co-Authored-By` or AI hints in commit messages. Commit after every task.
+- Version comparison is SERVER-side (`is_new_version` in response) — do not invent client-side compare logic.
+- iOS targets are OUT of scope (M2). Do not add `iosMain` or ios targets.
+
+---
+
+### Task 1: Gradle scaffolding — version catalog + `updraft-core` module
+
+**Files:**
+- Modify: `gradle/libs.versions.toml`
+- Modify: `settings.gradle.kts`
+- Create: `updraft-core/build.gradle.kts`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/core/Placeholder.kt` (deleted in Task 2)
+
+**Interfaces:**
+- Produces: buildable `:updraft-core` KMP module (android target), catalog entries `libs.ktor.*`, `libs.turbine`, plugins `kotlin-multiplatform`, `compose-multiplatform`, `compose-compiler`.
+
+- [ ] **Step 1: Add versions/libs/plugins to catalog**
+
+In `gradle/libs.versions.toml` add under `[versions]`:
+
+```toml
+composeMultiplatform = "1.9.3"
+ktor = "3.3.3"
+coroutines = "1.10.2"
+turbine = "1.2.1"
+lifecycleProcess = "2.9.4"
+```
+
+Under `[libraries]`:
+
+```toml
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleProcess" }
+```
+
+Under `[plugins]`:
+
+```toml
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+```
+
+- [ ] **Step 2: Create `updraft-core/build.gradle.kts`**
+
+```kotlin
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.maven.publish)
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.ktor.client.mock)
+ implementation(libs.turbine)
+ }
+ androidMain.dependencies {
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.androidx.startup)
+ implementation(libs.lifecycle.process)
+ implementation(libs.core.ktx)
+ }
+ }
+}
+
+android {
+ namespace = "com.appswithlove.updraft.core"
+ compileSdk = 36
+ defaultConfig {
+ minSdk = 23
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+mavenPublishing {
+ publishToMavenCentral(automaticRelease = true)
+ signAllPublications()
+}
+```
+
+- [ ] **Step 3: Register module + placeholder file**
+
+In `settings.gradle.kts` add `include(":updraft-core")` next to existing includes.
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/core/Placeholder.kt`:
+
+```kotlin
+package com.appswithlove.updraft.core
+
+internal const val PLACEHOLDER = true
+```
+
+- [ ] **Step 4: Verify build**
+
+Run: `./gradlew :updraft-core:assemble`
+Expected: BUILD SUCCESSFUL
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gradle/libs.versions.toml settings.gradle.kts updraft-core/
+git commit -m "Add updraft-core KMP module scaffolding"
+```
+
+---
+
+### Task 2: Move API models + `FeedbackType` to core commonMain
+
+**Files:**
+- Move (`git mv`): all 8 files from `updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/` and `.../api/response/` to `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/` and `.../response/`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/FeedbackType.kt`
+- Delete: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/core/Placeholder.kt`
+- Modify: `updraft-sdk/build.gradle.kts` (add `api(project(":updraft-core"))`)
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/FeedbackTypeTest.kt`
+
+**Interfaces:**
+- Produces: `@Serializable` request/response data classes (packages unchanged: `com.appswithlove.updraft.api.request` / `.response`); `enum class FeedbackType(val apiName: String) { Design("design"), Feedback("feedback"), Bug("bug") }`.
+- Note: old `FeedbackChoice` stays in updraft-sdk until Task 12 deletes the Views UI.
+
+- [ ] **Step 1: Write failing test**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/FeedbackTypeTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FeedbackTypeTest {
+ @Test
+ fun apiNames_matchServerContract() {
+ assertEquals("design", FeedbackType.Design.apiName)
+ assertEquals("feedback", FeedbackType.Feedback.apiName)
+ assertEquals("bug", FeedbackType.Bug.apiName)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.FeedbackTypeTest"`
+Expected: FAIL — unresolved reference `FeedbackType`
+
+- [ ] **Step 3: Move models, add FeedbackType**
+
+```bash
+mkdir -p updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api
+git mv updraft-sdk/src/main/java/com/appswithlove/updraft/api/request updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request
+git mv updraft-sdk/src/main/java/com/appswithlove/updraft/api/response updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response
+rm updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/core/Placeholder.kt
+```
+
+The 8 model files are pure kotlinx.serialization data classes — no source edits needed.
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/FeedbackType.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+enum class FeedbackType(val apiName: String) {
+ Design("design"),
+ Feedback("feedback"),
+ Bug("bug"),
+}
+```
+
+In `updraft-sdk/build.gradle.kts` `dependencies { }` add first line:
+
+```kotlin
+api(project(":updraft-core"))
+```
+
+- [ ] **Step 4: Run tests + full build**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest :updraft-sdk:assembleDebug`
+Expected: PASS + BUILD SUCCESSFUL (updraft-sdk resolves models from core)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "Move API models to updraft-core commonMain, add FeedbackType"
+```
+
+---
+
+### Task 3: `UpdraftSettings` + `LogLevel` in commonMain
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftSettings.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftSettingsTest.kt`
+
+**Interfaces:**
+- Produces:
+
+```kotlin
+enum class LogLevel { None, Error, Debug }
+
+class UpdraftSettings(
+ val appKey: String,
+ val sdkKey: String,
+ val baseUrl: String = BASE_URL_PROD,
+ val logLevel: LogLevel = LogLevel.Error,
+ val showFeedbackAlert: Boolean = true,
+ val feedbackEnabled: Boolean = true,
+ val storeRelease: Boolean = false,
+) {
+ fun shouldShowErrors(): Boolean
+ companion object { const val BASE_URL_PROD; const val BASE_URL_STAGING }
+}
+```
+
+- Note: old `Settings` stays in updraft-sdk until Task 12.
+
+- [ ] **Step 1: Write failing test**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftSettingsTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class UpdraftSettingsTest {
+ private fun settings(logLevel: LogLevel) =
+ UpdraftSettings(appKey = "a", sdkKey = "s", logLevel = logLevel)
+
+ @Test
+ fun shouldShowErrors_trueForErrorAndDebug() {
+ assertTrue(settings(LogLevel.Error).shouldShowErrors())
+ assertTrue(settings(LogLevel.Debug).shouldShowErrors())
+ assertFalse(settings(LogLevel.None).shouldShowErrors())
+ }
+
+ @Test
+ fun defaults_matchLegacySdk() {
+ val s = UpdraftSettings(appKey = "a", sdkKey = "s")
+ assertEquals("https://app.getupdraft.com/api/", s.baseUrl)
+ assertEquals(LogLevel.Error, s.logLevel)
+ assertTrue(s.feedbackEnabled)
+ assertFalse(s.storeRelease)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.UpdraftSettingsTest"`
+Expected: FAIL — unresolved reference `UpdraftSettings`
+
+- [ ] **Step 3: Implement**
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftSettings.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+enum class LogLevel { None, Error, Debug }
+
+class UpdraftSettings(
+ val appKey: String,
+ val sdkKey: String,
+ val baseUrl: String = BASE_URL_PROD,
+ val logLevel: LogLevel = LogLevel.Error,
+ val showFeedbackAlert: Boolean = true,
+ val feedbackEnabled: Boolean = true,
+ val storeRelease: Boolean = false,
+) {
+ fun shouldShowErrors(): Boolean =
+ logLevel == LogLevel.Error || logLevel == LogLevel.Debug
+
+ companion object {
+ const val BASE_URL_PROD = "https://app.getupdraft.com/api/"
+ const val BASE_URL_STAGING = "https://u2.mqd.me/api/"
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.UpdraftSettingsTest"`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Add UpdraftSettings and LogLevel to commonMain"
+```
+
+---
+
+### Task 4: `KeyValueStore` + `AppInfo` expect/actual
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.kt`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/AppInfo.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.android.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/AppInfo.android.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftContext.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftCoreInitializer.kt`
+- Create: `updraft-core/src/androidMain/AndroidManifest.xml`
+
+**Interfaces:**
+- Produces:
+
+```kotlin
+// commonMain
+interface KeyValueStore {
+ fun getBoolean(key: String, default: Boolean): Boolean
+ fun putBoolean(key: String, value: Boolean)
+}
+expect fun createKeyValueStore(name: String): KeyValueStore
+
+class AppInfo(
+ val versionCode: Long,
+ val versionName: String,
+ val systemVersion: String,
+ val deviceName: String,
+ val deviceUuid: String,
+)
+expect fun currentAppInfo(): AppInfo
+```
+
+- Android context comes from `UpdraftContext.application`, set by androidx.startup initializer — no manual init call.
+
+- [ ] **Step 1: Write commonMain declarations**
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+interface KeyValueStore {
+ fun getBoolean(key: String, default: Boolean): Boolean
+ fun putBoolean(key: String, value: Boolean)
+}
+
+expect fun createKeyValueStore(name: String): KeyValueStore
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/AppInfo.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+class AppInfo(
+ val versionCode: Long,
+ val versionName: String,
+ val systemVersion: String,
+ val deviceName: String,
+ val deviceUuid: String,
+)
+
+expect fun currentAppInfo(): AppInfo
+```
+
+- [ ] **Step 2: Write android actuals + context holder + initializer**
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftContext.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.app.Application
+
+object UpdraftContext {
+ lateinit var application: Application
+ internal set
+}
+```
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftCoreInitializer.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.app.Application
+import android.content.Context
+import androidx.startup.Initializer
+
+class UpdraftCoreInitializer : Initializer {
+ override fun create(context: Context): UpdraftContext {
+ UpdraftContext.application = context.applicationContext as Application
+ return UpdraftContext
+ }
+
+ override fun dependencies(): List>> = emptyList()
+}
+```
+
+`updraft-core/src/androidMain/AndroidManifest.xml`:
+
+```xml
+
+
+
+
+
+
+
+```
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.android.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+private class SharedPrefsKeyValueStore(private val prefs: SharedPreferences) : KeyValueStore {
+ override fun getBoolean(key: String, default: Boolean): Boolean = prefs.getBoolean(key, default)
+ override fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
+}
+
+actual fun createKeyValueStore(name: String): KeyValueStore =
+ SharedPrefsKeyValueStore(UpdraftContext.application.getSharedPreferences(name, Context.MODE_PRIVATE))
+```
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/AppInfo.android.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.annotation.SuppressLint
+import android.content.pm.PackageManager
+import android.os.Build
+import android.provider.Settings
+
+@SuppressLint("HardwareIds")
+actual fun currentAppInfo(): AppInfo {
+ val context = UpdraftContext.application
+ val packageInfo = try {
+ context.packageManager.getPackageInfo(context.packageName, 0)
+ } catch (e: PackageManager.NameNotFoundException) {
+ null
+ }
+ val versionCode = when {
+ packageInfo == null -> -1L
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> packageInfo.longVersionCode
+ else -> @Suppress("DEPRECATION") packageInfo.versionCode.toLong()
+ }
+ return AppInfo(
+ versionCode = versionCode,
+ versionName = packageInfo?.versionName.orEmpty(),
+ systemVersion = Build.VERSION.RELEASE.orEmpty(),
+ deviceName = Build.MODEL.orEmpty(),
+ deviceUuid = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
+ )
+}
+```
+
+- [ ] **Step 3: Verify build**
+
+Run: `./gradlew :updraft-core:assemble`
+Expected: BUILD SUCCESSFUL
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Add KeyValueStore and AppInfo expect/actual with startup initializer"
+```
+
+---
+
+### Task 5: `UpdraftApi` — Ktor client in commonMain
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/ApiException.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/api/UpdraftApiTest.kt`
+
+**Interfaces:**
+- Consumes: `UpdraftSettings`, `AppInfo`, request/response models (Task 2/3/4).
+- Produces:
+
+```kotlin
+class ApiException(message: String) : Exception(message)
+
+class UpdraftApi(
+ private val settings: UpdraftSettings,
+ private val appInfo: AppInfo,
+ engine: io.ktor.client.engine.HttpClientEngine? = null, // null = platform default
+) {
+ suspend fun checkLastVersion(): CheckLastVersionResponse // POST check_last_version/, sends versionCode as string
+ suspend fun getLastVersion(): GetLastVersionResponse // POST get_last_version/
+ suspend fun isFeedbackEnabled(): Boolean // POST feedback-mobile-enabled/, throws ApiException on error_code
+ fun sendFeedback(
+ screenshotPng: ByteArray,
+ type: FeedbackType,
+ description: String,
+ email: String,
+ ): Flow // multipart POST feedback-mobile/, emits upload progress 0..1, completes on success
+}
+```
+
+- [ ] **Step 1: Write failing tests**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/api/UpdraftApiTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.api
+
+import app.cash.turbine.test
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.UpdraftSettings
+import com.appswithlove.updraft.platform.AppInfo
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.toByteArray
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class UpdraftApiTest {
+
+ private val settings = UpdraftSettings(appKey = "APP", sdkKey = "SDK", baseUrl = "https://example.com/api/")
+ private val appInfo = AppInfo(42L, "1.2.3", "16", "Pixel", "uuid-1")
+
+ private fun jsonResponse(body: String) = headersOf(HttpHeaders.ContentType, "application/json") to body
+
+ @Test
+ fun checkLastVersion_sendsKeysAndVersionCode() = runTest {
+ var requestBody = ""
+ val engine = MockEngine { request ->
+ requestBody = String(request.body.toByteArray())
+ assertEquals("/api/check_last_version/", request.url.encodedPath)
+ respond(
+ """{"is_new_version":true,"is_autoupdate_enabled":true,"version":"9"}""",
+ HttpStatusCode.OK,
+ headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ }
+ val response = UpdraftApi(settings, appInfo, engine).checkLastVersion()
+ val sent = Json.parseToJsonElement(requestBody).jsonObject
+ assertEquals("APP", sent["app_key"]!!.jsonPrimitive.content)
+ assertEquals("SDK", sent["sdk_key"]!!.jsonPrimitive.content)
+ assertEquals("42", sent["version"]!!.jsonPrimitive.content)
+ assertTrue(response.isNewVersion)
+ }
+
+ @Test
+ fun isFeedbackEnabled_throwsApiExceptionOnErrorCodes() = runTest {
+ val engine = MockEngine {
+ respond(
+ """{"is_feedback_enabled":false,"error_code":["x"],"error_description":["broken"]}""",
+ HttpStatusCode.OK,
+ headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ }
+ val ex = assertFailsWith { UpdraftApi(settings, appInfo, engine).isFeedbackEnabled() }
+ assertEquals("broken", ex.message)
+ }
+
+ @Test
+ fun sendFeedback_uploadsMultipartAndCompletes() = runTest {
+ var contentType = ""
+ var bodyText = ""
+ val engine = MockEngine { request ->
+ contentType = request.body.contentType.toString()
+ bodyText = String(request.body.toByteArray())
+ assertEquals("/api/feedback-mobile/", request.url.encodedPath)
+ respond("""{}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
+ }
+ UpdraftApi(settings, appInfo, engine)
+ .sendFeedback(byteArrayOf(1, 2, 3), FeedbackType.Bug, "desc", "a@b.c")
+ .test {
+ awaitComplete()
+ }
+ assertTrue(contentType.startsWith("multipart/form-data"))
+ assertTrue(bodyText.contains("name=app_key") || bodyText.contains("name=\"app_key\""))
+ assertTrue(bodyText.contains("bug"))
+ assertTrue(bodyText.contains("Pixel"))
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.api.UpdraftApiTest"`
+Expected: FAIL — unresolved reference `UpdraftApi`
+
+- [ ] **Step 3: Implement**
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/ApiException.kt`:
+
+```kotlin
+package com.appswithlove.updraft.api
+
+class ApiException(message: String) : Exception(message)
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt`:
+
+```kotlin
+package com.appswithlove.updraft.api
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.LogLevel
+import com.appswithlove.updraft.UpdraftSettings
+import com.appswithlove.updraft.api.request.CheckLastVersionRequest
+import com.appswithlove.updraft.api.request.FeedbackEnabledRequest
+import com.appswithlove.updraft.api.request.GetLastVersionRequest
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.FeedbackEnabledResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.AppInfo
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel as KtorLogLevel
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.onUpload
+import io.ktor.client.request.forms.MultiPartFormDataContent
+import io.ktor.client.request.forms.formData
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.Headers
+import io.ktor.http.HttpHeaders
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+
+class UpdraftApi(
+ private val settings: UpdraftSettings,
+ private val appInfo: AppInfo,
+ engine: HttpClientEngine? = null,
+) {
+ private val client: HttpClient = (engine?.let { HttpClient(it) } ?: HttpClient()).config {
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(Logging) {
+ level = if (settings.logLevel == LogLevel.Debug) KtorLogLevel.BODY else KtorLogLevel.NONE
+ }
+ }
+
+ private fun url(path: String) = settings.baseUrl + path
+
+ suspend fun checkLastVersion(): CheckLastVersionResponse {
+ check(appInfo.versionCode >= 0) { "Version code is invalid" }
+ val request = CheckLastVersionRequest(
+ sdkKey = settings.sdkKey,
+ appKey = settings.appKey,
+ version = appInfo.versionCode.toString(),
+ )
+ return client.post(url("check_last_version/")) {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ suspend fun getLastVersion(): GetLastVersionResponse =
+ client.post(url("get_last_version/")) {
+ contentType(ContentType.Application.Json)
+ setBody(GetLastVersionRequest(sdkKey = settings.sdkKey, appKey = settings.appKey))
+ }.body()
+
+ suspend fun isFeedbackEnabled(): Boolean {
+ val response: FeedbackEnabledResponse = client.post(url("feedback-mobile-enabled/")) {
+ contentType(ContentType.Application.Json)
+ setBody(FeedbackEnabledRequest(sdkKey = settings.sdkKey, appKey = settings.appKey))
+ }.body()
+ if (response.errorCodes.isNotEmpty()) {
+ throw ApiException(response.errorDescriptions.firstOrNull().orEmpty())
+ }
+ return response.isFeedbackEnabled
+ }
+
+ fun sendFeedback(
+ screenshotPng: ByteArray,
+ type: FeedbackType,
+ description: String,
+ email: String,
+ ): Flow = callbackFlow {
+ val form = formData {
+ append(
+ "image",
+ screenshotPng,
+ Headers.build {
+ append(HttpHeaders.ContentType, "image/png")
+ append(HttpHeaders.ContentDisposition, "filename=\"UPDRAFT_SCREENSHOT.png\"")
+ },
+ )
+ append("app_key", settings.appKey)
+ append("sdk_key", settings.sdkKey)
+ append("tag", type.apiName)
+ append("description", description)
+ append("email", email)
+ append("build_version", appInfo.versionName)
+ append("system_version", appInfo.systemVersion)
+ append("device_name", appInfo.deviceName)
+ append("device_uuid", appInfo.deviceUuid)
+ }
+ val uploadJob = launch {
+ try {
+ client.post(url("feedback-mobile/")) {
+ setBody(MultiPartFormDataContent(form))
+ onUpload { bytesSentTotal, contentLength ->
+ if (contentLength != null && contentLength > 0) {
+ trySend(bytesSentTotal.toDouble() / contentLength.toDouble())
+ }
+ }
+ }
+ close()
+ } catch (t: Throwable) {
+ close(t)
+ }
+ }
+ awaitClose { uploadJob.cancel() }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.api.UpdraftApiTest"`
+Expected: PASS (3 tests). If a Ktor API name differs on this Ktor version (e.g. `onUpload` signature), fix implementation — not the assertions on wire format.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Add Ktor-based UpdraftApi with multipart feedback upload"
+```
+
+---
+
+### Task 6: Interactors in commonMain
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt`
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractorTest.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractorTest.kt`
+
+**Interfaces:**
+- Consumes: `UpdraftApi` (Task 5), `KeyValueStore` (Task 4).
+- Produces:
+
+```kotlin
+data class CheckUpdateResultModel(
+ val showAlert: Boolean,
+ val url: String? = null,
+ val version: String? = null,
+ val yourVersion: String? = null,
+ val createAt: String? = null,
+)
+class CheckUpdateInteractor(api: UpdraftApi) { suspend fun checkUpdate(): CheckUpdateResultModel }
+
+class CheckFeedbackResultModel(val showAlert: Boolean, val alertType: AlertType, val isFeedbackEnabled: Boolean) {
+ enum class AlertType { FeedbackDisabled, HowToGiveFeedback }
+}
+class CheckFeedbackEnabledInteractor(api: UpdraftApi, store: KeyValueStore) { suspend fun run(): CheckFeedbackResultModel }
+```
+
+- To make interactors testable without network, api calls go through an internal interface:
+
+```kotlin
+interface UpdraftApiContract {
+ suspend fun checkLastVersion(): CheckLastVersionResponse
+ suspend fun getLastVersion(): GetLastVersionResponse
+ suspend fun isFeedbackEnabled(): Boolean
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow
+}
+```
+
+Make `UpdraftApi` implement `UpdraftApiContract` (add `: UpdraftApiContract` and `override` markers in `UpdraftApi.kt`). Interactors take `UpdraftApiContract`.
+
+- [ ] **Step 1: Write failing tests**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractorTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private class FakeApi(
+ var checkResponse: CheckLastVersionResponse,
+ var lastVersionResponse: GetLastVersionResponse = GetLastVersionResponse(updateUrl = null),
+ var feedbackEnabled: Boolean = true,
+) : UpdraftApiContract {
+ override suspend fun checkLastVersion() = checkResponse
+ override suspend fun getLastVersion() = lastVersionResponse
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow = emptyFlow()
+}
+
+class CheckUpdateInteractorTest {
+
+ @Test
+ fun newVersionWithUrl_showsAlert() = runTest {
+ val api = FakeApi(
+ CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true, version = "5", yourVersion = "4", createAt = "2026-01-01T00:00:00Z"),
+ GetLastVersionResponse(updateUrl = "https://dl.example/app"),
+ )
+ val result = CheckUpdateInteractor(api).checkUpdate()
+ assertTrue(result.showAlert)
+ assertEquals("https://dl.example/app", result.url)
+ assertEquals("5", result.version)
+ assertEquals("4", result.yourVersion)
+ }
+
+ @Test
+ fun newVersionWithoutUrl_noAlert() = runTest {
+ val api = FakeApi(
+ CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true),
+ GetLastVersionResponse(updateUrl = null),
+ )
+ assertFalse(CheckUpdateInteractor(api).checkUpdate().showAlert)
+ }
+
+ @Test
+ fun autoupdateDisabled_noAlert() = runTest {
+ val api = FakeApi(CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = false))
+ assertFalse(CheckUpdateInteractor(api).checkUpdate().showAlert)
+ }
+}
+```
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractorTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.KeyValueStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private class FakeStore : KeyValueStore {
+ val map = mutableMapOf()
+ override fun getBoolean(key: String, default: Boolean) = map[key] ?: default
+ override fun putBoolean(key: String, value: Boolean) { map[key] = value }
+}
+
+private class FakeApi(var feedbackEnabled: Boolean) : UpdraftApiContract {
+ override suspend fun checkLastVersion() = CheckLastVersionResponse(isNewVersion = false, isAutoupdateEnabled = false)
+ override suspend fun getLastVersion() = GetLastVersionResponse()
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow = emptyFlow()
+}
+
+class CheckFeedbackEnabledInteractorTest {
+
+ @Test
+ fun becameEnabled_showsHowToAlert_andPersists() = runTest {
+ val store = FakeStore()
+ val result = CheckFeedbackEnabledInteractor(FakeApi(true), store).run()
+ assertTrue(result.showAlert)
+ assertEquals(CheckFeedbackResultModel.AlertType.HowToGiveFeedback, result.alertType)
+ assertTrue(store.map["is_feedback_enabled_property"]!!)
+ }
+
+ @Test
+ fun becameDisabled_showsDisabledAlert() = runTest {
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val result = CheckFeedbackEnabledInteractor(FakeApi(false), store).run()
+ assertTrue(result.showAlert)
+ assertEquals(CheckFeedbackResultModel.AlertType.FeedbackDisabled, result.alertType)
+ assertFalse(result.isFeedbackEnabled)
+ }
+
+ @Test
+ fun unchanged_noAlert() = runTest {
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ assertFalse(CheckFeedbackEnabledInteractor(FakeApi(true), store).run().showAlert)
+ assertFalse(CheckFeedbackEnabledInteractor(FakeApi(false), FakeStore()).run().showAlert)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.interactor.*"`
+Expected: FAIL — unresolved references
+
+- [ ] **Step 3: Implement**
+
+Add to `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt` (top of file, same package):
+
+```kotlin
+interface UpdraftApiContract {
+ suspend fun checkLastVersion(): CheckLastVersionResponse
+ suspend fun getLastVersion(): GetLastVersionResponse
+ suspend fun isFeedbackEnabled(): Boolean
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow
+}
+```
+
+and change `class UpdraftApi(...)` to `class UpdraftApi(...) : UpdraftApiContract`, adding `override` to the four members.
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+data class CheckUpdateResultModel(
+ val showAlert: Boolean,
+ val url: String? = null,
+ val version: String? = null,
+ val yourVersion: String? = null,
+ val createAt: String? = null,
+)
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.api.UpdraftApiContract
+
+class CheckUpdateInteractor(private val api: UpdraftApiContract) {
+
+ suspend fun checkUpdate(): CheckUpdateResultModel {
+ val check = api.checkLastVersion()
+ if (!check.isNewVersion || !check.isAutoupdateEnabled) {
+ return CheckUpdateResultModel(showAlert = false)
+ }
+ val url = api.getLastVersion().updateUrl
+ return CheckUpdateResultModel(
+ showAlert = url != null,
+ url = url,
+ version = check.version,
+ yourVersion = check.yourVersion,
+ createAt = check.createAt,
+ )
+ }
+}
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+class CheckFeedbackResultModel(
+ val showAlert: Boolean,
+ val alertType: AlertType,
+ val isFeedbackEnabled: Boolean,
+) {
+ enum class AlertType { FeedbackDisabled, HowToGiveFeedback }
+}
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt`:
+
+```kotlin
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.platform.KeyValueStore
+
+class CheckFeedbackEnabledInteractor(
+ private val api: UpdraftApiContract,
+ private val store: KeyValueStore,
+) {
+ suspend fun run(): CheckFeedbackResultModel {
+ val isEnabled = api.isFeedbackEnabled()
+ val previouslyEnabled = store.getBoolean(IS_FEEDBACK_ENABLED_PROPERTY, false)
+
+ val showAlert = isEnabled != previouslyEnabled
+ val alertType = if (!isEnabled && previouslyEnabled) {
+ CheckFeedbackResultModel.AlertType.FeedbackDisabled
+ } else {
+ CheckFeedbackResultModel.AlertType.HowToGiveFeedback
+ }
+ store.putBoolean(IS_FEEDBACK_ENABLED_PROPERTY, isEnabled)
+ return CheckFeedbackResultModel(showAlert, alertType, isEnabled)
+ }
+
+ companion object {
+ const val STORE_NAME = "feedback_enabled_storage"
+ private const val IS_FEEDBACK_ENABLED_PROPERTY = "is_feedback_enabled_property"
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.interactor.*"`
+Expected: PASS (6 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Port interactors to commonMain against UpdraftApiContract"
+```
+
+---
+
+### Task 7: Platform seams — `ShakeDetector`, `ScreenshotGrabber`, `UrlOpener`, `AppForegroundObserver`
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/Platform.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/Platform.android.kt`
+- Create: `updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/CurrentActivityManager.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/platform/ShakeMathTest.kt`
+
+**Interfaces:**
+- Produces (commonMain):
+
+```kotlin
+interface ShakeDetector { fun start(); fun stop(); fun setEnabled(enabled: Boolean) }
+expect fun createShakeDetector(onShake: () -> Unit): ShakeDetector
+
+interface ScreenshotGrabber { fun capturePng(): ByteArray? }
+expect fun createScreenshotGrabber(): ScreenshotGrabber
+
+expect fun openUrl(url: String)
+
+interface AppForegroundObserver { fun start() }
+expect fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver
+
+// pure shake math, testable in common
+fun isShakeGForce(x: Float, y: Float, z: Float, gravity: Float): Boolean
+```
+
+- Android actuals reuse existing `ShakeDetectorManager` sensor logic (constants from Global Constraints), `DefaultScreenshotProvider` draw logic, `Intent.ACTION_VIEW` for url, `ProcessLifecycleOwner` for foreground.
+- `CurrentActivityManager` (androidMain, note fixed spelling) is a copy of existing `CurrentActivityManger` registered by `UpdraftCoreInitializer` (extend Task 4 initializer: `UpdraftContext.application.registerActivityLifecycleCallbacks(CurrentActivityManager)`), exposing `val current: Activity?` as singleton `object`.
+
+- [ ] **Step 1: Write failing test for shake math**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/platform/ShakeMathTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ShakeMathTest {
+ private val g = 9.80665f
+
+ @Test
+ fun restingPhone_noShake() {
+ assertFalse(isShakeGForce(0f, 0f, g, g))
+ }
+
+ @Test
+ fun hardShake_detected() {
+ assertTrue(isShakeGForce(3f * g, 0f, 0f, g))
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.platform.ShakeMathTest"`
+Expected: FAIL — unresolved reference `isShakeGForce`
+
+- [ ] **Step 3: Implement commonMain declarations**
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/Platform.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import kotlin.math.sqrt
+
+interface ShakeDetector {
+ fun start()
+ fun stop()
+ fun setEnabled(enabled: Boolean)
+}
+
+expect fun createShakeDetector(onShake: () -> Unit): ShakeDetector
+
+interface ScreenshotGrabber {
+ fun capturePng(): ByteArray?
+}
+
+expect fun createScreenshotGrabber(): ScreenshotGrabber
+
+expect fun openUrl(url: String)
+
+interface AppForegroundObserver {
+ fun start()
+}
+
+expect fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver
+
+const val SHAKE_THRESHOLD_GRAVITY = 2.7f
+
+fun isShakeGForce(x: Float, y: Float, z: Float, gravity: Float): Boolean {
+ val gX = x / gravity
+ val gY = y / gravity
+ val gZ = z / gravity
+ return sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()) > SHAKE_THRESHOLD_GRAVITY
+}
+```
+
+- [ ] **Step 4: Run shake math test to verify it passes**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.platform.ShakeMathTest"`
+Expected: PASS
+
+- [ ] **Step 5: Implement android actuals**
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/CurrentActivityManager.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+
+object CurrentActivityManager : Application.ActivityLifecycleCallbacks {
+
+ var current: Activity? = null
+ private set
+
+ private val listeners = mutableSetOf<(Activity?) -> Unit>()
+
+ fun addListener(listener: (Activity?) -> Unit) {
+ listeners.add(listener)
+ listener(current)
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ current = activity
+ listeners.forEach { it(activity) }
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ current = null
+ listeners.forEach { it(null) }
+ }
+
+ override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
+ override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+}
+```
+
+In `UpdraftCoreInitializer.create` add before `return`:
+
+```kotlin
+UpdraftContext.application.registerActivityLifecycleCallbacks(CurrentActivityManager)
+```
+
+`updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/Platform.android.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import androidx.core.net.toUri
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import java.io.ByteArrayOutputStream
+
+private class AndroidShakeDetector(private val onShake: () -> Unit) :
+ ShakeDetector, SensorEventListener, DefaultLifecycleObserver {
+
+ private val sensorManager =
+ UpdraftContext.application.getSystemService(android.content.Context.SENSOR_SERVICE) as SensorManager
+ private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+
+ private var shakeTimestamp = 0L
+ private var enabled = true
+
+ override fun start() {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override fun stop() {
+ sensorManager.unregisterListener(this)
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ this.enabled = enabled
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ accelerometer?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) }
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ sensorManager.unregisterListener(this)
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ val (x, y, z) = event.values
+ if (!isShakeGForce(x, y, z, SensorManager.GRAVITY_EARTH)) return
+
+ val now = System.currentTimeMillis()
+ if (shakeTimestamp + SHAKE_SLOP_TIME_MS > now) return
+ shakeTimestamp = now
+
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
+
+ companion object {
+ private const val SHAKE_SLOP_TIME_MS = 500
+ }
+}
+
+actual fun createShakeDetector(onShake: () -> Unit): ShakeDetector = AndroidShakeDetector(onShake)
+
+private class AndroidScreenshotGrabber : ScreenshotGrabber {
+ override fun capturePng(): ByteArray? {
+ val activity = CurrentActivityManager.current ?: return null
+ val view = activity.window.decorView.rootView
+ if (view.width == 0 || view.height == 0) return null
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ bitmap.recycle()
+ return stream.toByteArray()
+ }
+}
+
+actual fun createScreenshotGrabber(): ScreenshotGrabber = AndroidScreenshotGrabber()
+
+actual fun openUrl(url: String) {
+ val activity = CurrentActivityManager.current ?: return
+ activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+}
+
+private class AndroidForegroundObserver(
+ private val onForeground: () -> Unit,
+ private val onBackground: () -> Unit,
+) : AppForegroundObserver, DefaultLifecycleObserver {
+ override fun start() {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override fun onStart(owner: LifecycleOwner) = onForeground()
+ override fun onStop(owner: LifecycleOwner) = onBackground()
+}
+
+actual fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver =
+ AndroidForegroundObserver(onForeground, onBackground)
+```
+
+- [ ] **Step 6: Verify build + all core tests**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest :updraft-core:assemble`
+Expected: BUILD SUCCESSFUL, all tests PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Add platform seams: shake, screenshot, url, foreground observer"
+```
+
+---
+
+### Task 8: `Updraft` entry object + events in commonMain
+
+**Files:**
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/Updraft.kt` (core; legacy class in updraft-sdk is removed in Task 12)
+- Create: `updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftEvent.kt`
+- Test: `updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftControllerTest.kt`
+
+**Interfaces:**
+- Consumes: everything from Tasks 3–7.
+- Produces:
+
+```kotlin
+sealed interface UpdraftEvent {
+ data class UpdateAvailable(val url: String, val version: String?, val yourVersion: String?, val createAt: String?) : UpdraftEvent
+ data object ShowFeedbackHint : UpdraftEvent // "how to give feedback" alert
+ data object FeedbackDisabled : UpdraftEvent // feedback got disabled alert
+ data object FeedbackRequested : UpdraftEvent // shake or showFeedback(); payload fetched via takePendingScreenshot()
+ data object CloseFeedback : UpdraftEvent // feedback disabled while feedback UI open
+ data class Error(val cause: Throwable) : UpdraftEvent
+}
+
+fun interface FeedbackUiPresenter { fun presentFeedback(screenshotPng: ByteArray?) }
+
+object Updraft {
+ fun start(settings: UpdraftSettings)
+ fun checkForUpdate() // fire-and-forget, emits UpdateAvailable/Error
+ fun showFeedback() // manual trigger, same path as shake
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow
+ fun openUpdateUrl(url: String)
+ fun setFeedbackUiPresenter(presenter: FeedbackUiPresenter?)
+ fun onFeedbackUiClosed() // re-arms shake detector
+ val events: SharedFlow
+ val settings: UpdraftSettings // throws if not started
+}
+
+// internal, constructor-injected for tests:
+internal class UpdraftController(
+ settings: UpdraftSettings,
+ api: UpdraftApiContract,
+ store: KeyValueStore,
+ scope: CoroutineScope,
+) { ... }
+```
+
+- `Updraft` object is a thin holder delegating to an internal `UpdraftController` so logic is testable without platform actuals. `start()` builds the controller with real dependencies plus shake detector + foreground observer; on foreground it runs update check + feedback-enabled check (mirrors old `AppUpdateManager.onStart` + `CheckFeedbackEnabledManager.onStart`); on shake it captures screenshot and either calls the presenter or emits `FeedbackRequested`.
+
+- [ ] **Step 1: Write failing controller test**
+
+`updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftControllerTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+import app.cash.turbine.test
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.KeyValueStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+private class FakeStore : KeyValueStore {
+ val map = mutableMapOf()
+ override fun getBoolean(key: String, default: Boolean) = map[key] ?: default
+ override fun putBoolean(key: String, value: Boolean) { map[key] = value }
+}
+
+private class FakeApi : UpdraftApiContract {
+ var check = CheckLastVersionResponse(isNewVersion = false, isAutoupdateEnabled = false)
+ var last = GetLastVersionResponse()
+ var feedbackEnabled = true
+ override suspend fun checkLastVersion() = check
+ override suspend fun getLastVersion() = last
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow = emptyFlow()
+}
+
+class UpdraftControllerTest {
+
+ private val settings = UpdraftSettings(appKey = "a", sdkKey = "s", showFeedbackAlert = true)
+
+ @Test
+ fun onForeground_updateAvailable_emitsEvent() = runTest {
+ val api = FakeApi().apply {
+ check = CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true, version = "7")
+ last = GetLastVersionResponse(updateUrl = "https://u")
+ feedbackEnabled = true
+ }
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val controller = UpdraftController(settings, api, store, backgroundScope)
+
+ controller.events.test {
+ controller.onForeground()
+ val event = awaitItem()
+ val update = assertIs(event)
+ assertEquals("https://u", update.url)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onForeground_feedbackBecameDisabled_emitsFeedbackDisabled() = runTest {
+ val api = FakeApi().apply { feedbackEnabled = false }
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val controller = UpdraftController(settings, api, store, backgroundScope)
+
+ controller.events.test {
+ controller.onForeground()
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun feedbackRequested_emittedOnShakePath() = runTest {
+ val controller = UpdraftController(settings, FakeApi(), FakeStore(), backgroundScope)
+ controller.events.test {
+ controller.onFeedbackTriggered(screenshotPng = byteArrayOf(1))
+ assertIs(awaitItem())
+ assertEquals(1, controller.takePendingScreenshot()!!.size)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest --tests "com.appswithlove.updraft.UpdraftControllerTest"`
+Expected: FAIL — unresolved reference `UpdraftController`
+
+- [ ] **Step 3: Implement**
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftEvent.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+sealed interface UpdraftEvent {
+ data class UpdateAvailable(
+ val url: String,
+ val version: String?,
+ val yourVersion: String?,
+ val createAt: String?,
+ ) : UpdraftEvent
+
+ data object ShowFeedbackHint : UpdraftEvent
+ data object FeedbackDisabled : UpdraftEvent
+ data object FeedbackRequested : UpdraftEvent
+ data object CloseFeedback : UpdraftEvent
+ data class Error(val cause: Throwable) : UpdraftEvent
+}
+
+fun interface FeedbackUiPresenter {
+ fun presentFeedback(screenshotPng: ByteArray?)
+}
+```
+
+`updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/Updraft.kt`:
+
+```kotlin
+package com.appswithlove.updraft
+
+import com.appswithlove.updraft.api.UpdraftApi
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.interactor.CheckFeedbackEnabledInteractor
+import com.appswithlove.updraft.interactor.CheckFeedbackResultModel
+import com.appswithlove.updraft.interactor.CheckUpdateInteractor
+import com.appswithlove.updraft.platform.KeyValueStore
+import com.appswithlove.updraft.platform.createAppForegroundObserver
+import com.appswithlove.updraft.platform.createKeyValueStore
+import com.appswithlove.updraft.platform.createScreenshotGrabber
+import com.appswithlove.updraft.platform.createShakeDetector
+import com.appswithlove.updraft.platform.currentAppInfo
+import com.appswithlove.updraft.platform.openUrl
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+
+internal class UpdraftController(
+ private val settings: UpdraftSettings,
+ private val api: UpdraftApiContract,
+ store: KeyValueStore,
+ private val scope: CoroutineScope,
+) {
+ private val checkUpdateInteractor = CheckUpdateInteractor(api)
+ private val checkFeedbackInteractor = CheckFeedbackEnabledInteractor(api, store)
+
+ private val _events = MutableSharedFlow(extraBufferCapacity = 16)
+ val events: SharedFlow = _events
+
+ private var updateAlertShown = false
+ private var feedbackHintShown = false
+ private var pendingScreenshot: ByteArray? = null
+
+ var feedbackUiPresenter: FeedbackUiPresenter? = null
+
+ fun onForeground() {
+ if (settings.showFeedbackAlert && settings.feedbackEnabled && !feedbackHintShown) {
+ feedbackHintShown = true
+ _events.tryEmit(UpdraftEvent.ShowFeedbackHint)
+ }
+ checkForUpdate()
+ checkFeedbackEnabled()
+ }
+
+ fun checkForUpdate() {
+ scope.launch {
+ try {
+ val result = checkUpdateInteractor.checkUpdate()
+ val url = result.url
+ if (result.showAlert && url != null && !updateAlertShown) {
+ updateAlertShown = true
+ _events.tryEmit(
+ UpdraftEvent.UpdateAvailable(url, result.version, result.yourVersion, result.createAt),
+ )
+ }
+ } catch (t: Throwable) {
+ _events.tryEmit(UpdraftEvent.Error(t))
+ }
+ }
+ }
+
+ private fun checkFeedbackEnabled() {
+ scope.launch {
+ try {
+ val result = checkFeedbackInteractor.run()
+ if (!result.isFeedbackEnabled) {
+ _events.tryEmit(UpdraftEvent.CloseFeedback)
+ }
+ if (result.showAlert) {
+ when (result.alertType) {
+ CheckFeedbackResultModel.AlertType.FeedbackDisabled ->
+ _events.tryEmit(UpdraftEvent.FeedbackDisabled)
+ CheckFeedbackResultModel.AlertType.HowToGiveFeedback ->
+ if (settings.showFeedbackAlert) _events.tryEmit(UpdraftEvent.ShowFeedbackHint)
+ }
+ }
+ } catch (t: Throwable) {
+ _events.tryEmit(UpdraftEvent.Error(t))
+ }
+ }
+ }
+
+ fun onFeedbackTriggered(screenshotPng: ByteArray?) {
+ pendingScreenshot = screenshotPng
+ val presenter = feedbackUiPresenter
+ if (presenter != null) {
+ presenter.presentFeedback(screenshotPng)
+ } else {
+ _events.tryEmit(UpdraftEvent.FeedbackRequested)
+ }
+ }
+
+ fun takePendingScreenshot(): ByteArray? {
+ val screenshot = pendingScreenshot
+ pendingScreenshot = null
+ return screenshot
+ }
+
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow =
+ api.sendFeedback(screenshotPng, type, description, email)
+}
+
+object Updraft {
+ private var controller: UpdraftController? = null
+ private var currentSettings: UpdraftSettings? = null
+ private var shakeDetector: com.appswithlove.updraft.platform.ShakeDetector? = null
+
+ val settings: UpdraftSettings
+ get() = checkNotNull(currentSettings) { "Must call Updraft.start() first" }
+
+ val events: SharedFlow
+ get() = requireController().events
+
+ fun start(settings: UpdraftSettings) {
+ if (controller != null) return
+ currentSettings = settings
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val api = UpdraftApi(settings, currentAppInfo())
+ val store = createKeyValueStore(CheckFeedbackEnabledInteractor.STORE_NAME)
+ val newController = UpdraftController(settings, api, store, scope)
+ controller = newController
+
+ if (settings.feedbackEnabled) {
+ val detector = createShakeDetector { showFeedback() }
+ shakeDetector = detector
+ detector.start()
+ }
+ createAppForegroundObserver(
+ onForeground = { newController.onForeground() },
+ onBackground = { },
+ ).start()
+ }
+
+ fun checkForUpdate() = requireController().checkForUpdate()
+
+ fun showFeedback() {
+ val screenshot = createScreenshotGrabber().capturePng()
+ requireController().onFeedbackTriggered(screenshot)
+ }
+
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow =
+ requireController().sendFeedback(screenshotPng, type, description, email)
+
+ fun openUpdateUrl(url: String) = openUrl(url)
+
+ fun setFeedbackUiPresenter(presenter: FeedbackUiPresenter?) {
+ requireController().feedbackUiPresenter = presenter
+ }
+
+ fun onFeedbackUiClosed() {
+ shakeDetector?.setEnabled(true)
+ }
+
+ internal fun takePendingScreenshot(): ByteArray? = requireController().takePendingScreenshot()
+
+ private fun requireController(): UpdraftController =
+ checkNotNull(controller) { "Must call Updraft.start() first" }
+}
+```
+
+Note: `Updraft.kt` in updraft-sdk still exists with the same FQN — that causes a duplicate-class conflict ONLY when both are on the classpath. To avoid it now, this task also renames the legacy class: in `updraft-sdk`, rename `Updraft` to `LegacyUpdraft` (IDE-style rename: update `Updraft.getInstance()` call sites in `UpdraftSdkUi.kt`, `FeedbackActivity.kt`, `FeedbackFormPresenter.kt`, `app/src/main/java/com/appswithlove/updraftsdk/App.kt` — grep `Updraft.initialize|getInstance` to catch all). Legacy code is deleted entirely in Task 12.
+
+- [ ] **Step 4: Run tests**
+
+Run: `./gradlew :updraft-core:testDebugUnitTest :updraft-sdk:assembleDebug`
+Expected: PASS + BUILD SUCCESSFUL
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "Add Updraft entry object with event flow and controller"
+```
+
+---
+
+### Task 9: `updraft-ui-compose` module + string resources
+
+**Files:**
+- Create: `updraft-ui-compose/build.gradle.kts`
+- Modify: `settings.gradle.kts` (add `include(":updraft-ui-compose")`)
+- Create: `updraft-ui-compose/src/commonMain/composeResources/values/strings.xml`
+- Create: `updraft-ui-compose/src/commonMain/composeResources/values-de/strings.xml`
+
+**Interfaces:**
+- Produces: buildable CMP module `:updraft-ui-compose` depending on `:updraft-core`; compose-resources strings accessible as `Res.string.updraft_*`.
+
+- [ ] **Step 1: Create module build file**
+
+`updraft-ui-compose/build.gradle.kts`:
+
+```kotlin
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.maven.publish)
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":updraft-core"))
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(compose.components.resources)
+ }
+ }
+}
+
+compose.resources {
+ packageOfResClass = "com.appswithlove.updraft.ui.resources"
+ publicResClass = false
+}
+
+android {
+ namespace = "com.appswithlove.updraft.ui"
+ compileSdk = 36
+ defaultConfig {
+ minSdk = 23
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+mavenPublishing {
+ publishToMavenCentral(automaticRelease = true)
+ signAllPublications()
+}
+```
+
+Add `include(":updraft-ui-compose")` to `settings.gradle.kts`.
+
+- [ ] **Step 2: Migrate strings**
+
+Copy every `updraft_*` string from `updraft-sdk/src/main/res/values/strings.xml` and `values-de/strings.xml` into the two new `composeResources/values*/strings.xml` files verbatim (same names, same `en`/`de` content). Plurals (`updraft_relative_weeksAgo`, `updraft_relative_monthsAgo`) move as `` too — compose-resources supports plurals via `pluralStringResource`.
+
+Source files stay in updraft-sdk until Task 12 (Views UI still uses them).
+
+- [ ] **Step 3: Verify build**
+
+Run: `./gradlew :updraft-ui-compose:assemble`
+Expected: BUILD SUCCESSFUL, generated `Res` class contains `updraft_feedbackDialog_title` etc.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add settings.gradle.kts updraft-ui-compose/
+git commit -m "Add updraft-ui-compose CMP module with migrated strings"
+```
+
+---
+
+### Task 10: `DrawingController` + `DrawingCanvas` composable (FreeDraw port)
+
+**Files:**
+- Create: `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingController.kt`
+- Create: `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingCanvas.kt`
+- Test: `updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/drawing/DrawingControllerTest.kt`
+- Modify: `updraft-ui-compose/build.gradle.kts` (add `commonTest.dependencies { implementation(kotlin("test")) }`)
+
+**Interfaces:**
+- Produces:
+
+```kotlin
+class DrawnPath(val points: List, val color: Color, val strokeWidthPx: Float)
+
+class DrawingController(initialColor: Color = Color.Red, initialStrokeWidthPx: Float = 12f) {
+ val paths: List // committed paths (snapshot-state backed)
+ var color: Color
+ var strokeWidthPx: Float
+ val canUndo: Boolean
+ val canRedo: Boolean
+ fun startPath(point: Offset)
+ fun addPoint(point: Offset)
+ fun endPath()
+ fun undo()
+ fun redo()
+}
+
+@Composable
+fun DrawingCanvas(controller: DrawingController, modifier: Modifier = Modifier)
+```
+
+- Mirrors FreeDrawView semantics: new stroke clears redo stack; undo moves last path to redo stack.
+
+- [ ] **Step 1: Write failing tests**
+
+`updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/drawing/DrawingControllerTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.ui.geometry.Offset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DrawingControllerTest {
+
+ private fun DrawingController.drawStroke(vararg points: Offset) {
+ startPath(points.first())
+ points.drop(1).forEach { addPoint(it) }
+ endPath()
+ }
+
+ @Test
+ fun stroke_commitsPath() {
+ val c = DrawingController()
+ c.drawStroke(Offset(0f, 0f), Offset(10f, 10f))
+ assertEquals(1, c.paths.size)
+ assertEquals(2, c.paths.first().points.size)
+ }
+
+ @Test
+ fun undoRedo_roundTrips() {
+ val c = DrawingController()
+ c.drawStroke(Offset(0f, 0f), Offset(1f, 1f))
+ assertTrue(c.canUndo)
+ c.undo()
+ assertEquals(0, c.paths.size)
+ assertTrue(c.canRedo)
+ c.redo()
+ assertEquals(1, c.paths.size)
+ }
+
+ @Test
+ fun newStroke_clearsRedoStack() {
+ val c = DrawingController()
+ c.drawStroke(Offset(0f, 0f), Offset(1f, 1f))
+ c.undo()
+ c.drawStroke(Offset(2f, 2f), Offset(3f, 3f))
+ assertFalse(c.canRedo)
+ assertEquals(1, c.paths.size)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `./gradlew :updraft-ui-compose:testDebugUnitTest --tests "com.appswithlove.updraft.ui.drawing.DrawingControllerTest"`
+Expected: FAIL — unresolved reference `DrawingController`
+
+- [ ] **Step 3: Implement**
+
+`updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingController.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+
+class DrawnPath(
+ val points: List,
+ val color: Color,
+ val strokeWidthPx: Float,
+)
+
+class DrawingController(
+ initialColor: Color = Color.Red,
+ initialStrokeWidthPx: Float = 12f,
+) {
+ private val committedPaths = mutableStateListOf()
+ private val redoStack = mutableStateListOf()
+ private val currentPoints = mutableStateListOf()
+
+ val paths: List get() = committedPaths
+ val currentStroke: List get() = currentPoints
+
+ var color by mutableStateOf(initialColor)
+ var strokeWidthPx by mutableStateOf(initialStrokeWidthPx)
+
+ val canUndo: Boolean get() = committedPaths.isNotEmpty()
+ val canRedo: Boolean get() = redoStack.isNotEmpty()
+
+ fun startPath(point: Offset) {
+ currentPoints.clear()
+ currentPoints.add(point)
+ }
+
+ fun addPoint(point: Offset) {
+ currentPoints.add(point)
+ }
+
+ fun endPath() {
+ if (currentPoints.isNotEmpty()) {
+ committedPaths.add(DrawnPath(currentPoints.toList(), color, strokeWidthPx))
+ redoStack.clear()
+ currentPoints.clear()
+ }
+ }
+
+ fun undo() {
+ if (committedPaths.isNotEmpty()) {
+ redoStack.add(committedPaths.removeAt(committedPaths.lastIndex))
+ }
+ }
+
+ fun redo() {
+ if (redoStack.isNotEmpty()) {
+ committedPaths.add(redoStack.removeAt(redoStack.lastIndex))
+ }
+ }
+}
+```
+
+`updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingCanvas.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+
+@Composable
+fun DrawingCanvas(controller: DrawingController, modifier: Modifier = Modifier) {
+ Canvas(
+ modifier = modifier.pointerInput(controller) {
+ awaitPointerEventScope {
+ while (true) {
+ val down = awaitPointerEvent().changes.firstOrNull { it.pressed } ?: continue
+ controller.startPath(down.position)
+ down.consume()
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull() ?: break
+ if (!change.pressed) {
+ controller.endPath()
+ break
+ }
+ controller.addPoint(change.position)
+ change.consume()
+ }
+ }
+ }
+ },
+ ) {
+ controller.paths.forEach { drawStroke(it.points, it.color, it.strokeWidthPx) }
+ drawStroke(controller.currentStroke, controller.color, controller.strokeWidthPx)
+ }
+}
+
+private fun DrawScope.drawStroke(points: List, color: Color, strokeWidthPx: Float) {
+ if (points.isEmpty()) return
+ if (points.size == 1) {
+ drawCircle(color, radius = strokeWidthPx / 2f, center = points.first())
+ return
+ }
+ val path = Path().apply {
+ moveTo(points.first().x, points.first().y)
+ points.drop(1).forEach { lineTo(it.x, it.y) }
+ }
+ drawPath(path, color, style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round, join = StrokeJoin.Round))
+}
+```
+
+Add to `updraft-ui-compose/build.gradle.kts` sourceSets:
+
+```kotlin
+commonTest.dependencies {
+ implementation(kotlin("test"))
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `./gradlew :updraft-ui-compose:testDebugUnitTest --tests "com.appswithlove.updraft.ui.drawing.DrawingControllerTest"`
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add updraft-ui-compose/
+git commit -m "Add DrawingController and DrawingCanvas (FreeDrawView port)"
+```
+
+---
+
+### Task 11: Feedback screen + dialogs in CMP
+
+**Files:**
+- Create: `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenState.kt`
+- Create: `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreen.kt`
+- Create: `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/UpdraftDialogs.kt`
+- Test: `updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenStateTest.kt`
+
+**Interfaces:**
+- Consumes: `Updraft.sendFeedback` (Task 8), `DrawingCanvas`/`DrawingController` (Task 10), `Res.string.updraft_*` (Task 9), `FeedbackType` (Task 2).
+- Produces:
+
+```kotlin
+class FeedbackScreenState(
+ private val send: (screenshot: ByteArray, type: FeedbackType, description: String, email: String) -> Flow,
+ private val scope: CoroutineScope,
+) {
+ var selectedType: FeedbackType?
+ var description: String
+ var email: String
+ val uploadProgress: Double? // null = idle
+ val result: Result? // null until finished
+ val canSend: Boolean // type selected && not uploading
+ fun sendFeedback(screenshotPng: ByteArray)
+}
+
+@Composable
+fun FeedbackScreen(
+ screenshotPng: ByteArray?,
+ onClose: () -> Unit,
+ state: FeedbackScreenState = remember { ... default wired to Updraft ... },
+) // two-step: annotate screenshot (DrawingCanvas overlay) -> form -> send
+
+@Composable fun UpdateAvailableDialog(event: UpdraftEvent.UpdateAvailable, onOpen: (String) -> Unit, onLater: () -> Unit)
+@Composable fun FeedbackHintDialog(onDismiss: () -> Unit)
+@Composable fun FeedbackDisabledDialog(onDismiss: () -> Unit)
+@Composable fun UpdraftEventHost(events: Flow, onFeedbackRequested: () -> Unit)
+ // collects events, renders the three dialogs; calls onFeedbackRequested for UpdraftEvent.FeedbackRequested
+```
+
+- Screenshot rendering: decode `ByteArray` PNG to `ImageBitmap` via expect/actual `fun decodePng(bytes: ByteArray): ImageBitmap` in `updraft-ui-compose` (androidMain: `BitmapFactory.decodeByteArray(...).asImageBitmap()`); merged annotated bitmap produced by drawing paths over the image in a `Canvas`-backed `ImageBitmap` via expect/actual `fun renderAnnotated(base: ByteArray, paths: List): ByteArray` (androidMain: mutable Bitmap + android.graphics.Canvas + Paint per DrawnPath).
+- `UpdateAvailableDialog` message: reuse simplified relative-age copy — show `updraft_updateAvailable_yourVersion` line when `yourVersion` present, plain description otherwise (drop `DateUtils` relative-age formatting; note in KDoc that relative age returns in a follow-up).
+
+- [ ] **Step 1: Write failing state test**
+
+`updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenStateTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import com.appswithlove.updraft.FeedbackType
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class FeedbackScreenStateTest {
+
+ @Test
+ fun canSend_requiresTypeSelection() = runTest {
+ val state = FeedbackScreenState(send = { _, _, _, _ -> flowOf() }, scope = backgroundScope)
+ assertFalse(state.canSend)
+ state.selectedType = FeedbackType.Bug
+ assertTrue(state.canSend)
+ }
+
+ @Test
+ fun sendFeedback_success_setsResult() = runTest {
+ val state = FeedbackScreenState(send = { _, _, _, _ -> flowOf(0.5, 1.0) }, scope = backgroundScope)
+ state.selectedType = FeedbackType.Feedback
+ state.sendFeedback(byteArrayOf(1))
+ advanceUntilIdle()
+ assertNotNull(state.result)
+ assertTrue(state.result!!.isSuccess)
+ }
+
+ @Test
+ fun sendFeedback_failure_setsFailureResult() = runTest {
+ val state = FeedbackScreenState(
+ send = { _, _, _, _ -> flow { throw IllegalStateException("boom") } },
+ scope = backgroundScope,
+ )
+ state.selectedType = FeedbackType.Bug
+ state.sendFeedback(byteArrayOf(1))
+ advanceUntilIdle()
+ assertTrue(state.result!!.isFailure)
+ assertEquals("boom", state.result!!.exceptionOrNull()!!.message)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :updraft-ui-compose:testDebugUnitTest --tests "com.appswithlove.updraft.ui.feedback.FeedbackScreenStateTest"`
+Expected: FAIL — unresolved reference `FeedbackScreenState`
+
+- [ ] **Step 3: Implement state holder**
+
+`updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenState.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.appswithlove.updraft.FeedbackType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.launch
+
+class FeedbackScreenState(
+ private val send: (screenshot: ByteArray, type: FeedbackType, description: String, email: String) -> Flow,
+ private val scope: CoroutineScope,
+) {
+ var selectedType: FeedbackType? by mutableStateOf(null)
+ var description: String by mutableStateOf("")
+ var email: String by mutableStateOf("")
+ var uploadProgress: Double? by mutableStateOf(null)
+ private set
+ var result: Result? by mutableStateOf(null)
+ private set
+
+ val canSend: Boolean get() = selectedType != null && uploadProgress == null
+
+ fun sendFeedback(screenshotPng: ByteArray) {
+ val type = selectedType ?: return
+ uploadProgress = 0.0
+ scope.launch {
+ send(screenshotPng, type, description, email)
+ .catch { t ->
+ uploadProgress = null
+ result = Result.failure(t)
+ }
+ .onCompletion { cause ->
+ if (cause == null && result == null) {
+ uploadProgress = null
+ result = Result.success(Unit)
+ }
+ }
+ .collect { progress -> uploadProgress = progress }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run state tests to verify they pass**
+
+Run: `./gradlew :updraft-ui-compose:testDebugUnitTest --tests "com.appswithlove.updraft.ui.feedback.FeedbackScreenStateTest"`
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Implement composables**
+
+`updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/UpdraftDialogs.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.dialogs
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.ui.resources.Res
+import com.appswithlove.updraft.ui.resources.updraft_button_cancel
+import com.appswithlove.updraft.ui.resources.updraft_button_ok
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDialog_description
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDialog_title
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDisabled_description
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDisabled_title
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_description
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_laterButton
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_openButton
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_title
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_titleWithVersion
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_yourVersion
+import kotlinx.coroutines.flow.Flow
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun UpdateAvailableDialog(
+ event: UpdraftEvent.UpdateAvailable,
+ onOpen: (String) -> Unit,
+ onLater: () -> Unit,
+) {
+ val title = if (!event.version.isNullOrBlank()) {
+ stringResource(Res.string.updraft_updateAvailable_titleWithVersion, event.version!!)
+ } else {
+ stringResource(Res.string.updraft_updateAvailable_title)
+ }
+ val message = if (!event.yourVersion.isNullOrBlank()) {
+ stringResource(Res.string.updraft_updateAvailable_yourVersion, event.yourVersion!!)
+ } else {
+ stringResource(Res.string.updraft_updateAvailable_description)
+ }
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text(title) },
+ text = { Text(message) },
+ confirmButton = {
+ TextButton(onClick = { onOpen(event.url) }) {
+ Text(stringResource(Res.string.updraft_updateAvailable_openButton))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onLater) {
+ Text(stringResource(Res.string.updraft_updateAvailable_laterButton))
+ }
+ },
+ )
+}
+
+@Composable
+fun FeedbackHintDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.updraft_feedbackDialog_title)) },
+ text = { Text(stringResource(Res.string.updraft_feedbackDialog_description)) },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.updraft_button_ok)) }
+ },
+ )
+}
+
+@Composable
+fun FeedbackDisabledDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.updraft_feedbackDisabled_title)) },
+ text = { Text(stringResource(Res.string.updraft_feedbackDisabled_description)) },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.updraft_button_cancel)) }
+ },
+ )
+}
+
+@Composable
+fun UpdraftEventHost(events: Flow, onFeedbackRequested: () -> Unit) {
+ var updateEvent by remember { mutableStateOf(null) }
+ var showHint by remember { mutableStateOf(false) }
+ var showDisabled by remember { mutableStateOf(false) }
+
+ LaunchedEffect(events) {
+ events.collect { event ->
+ when (event) {
+ is UpdraftEvent.UpdateAvailable -> updateEvent = event
+ UpdraftEvent.ShowFeedbackHint -> showHint = true
+ UpdraftEvent.FeedbackDisabled -> showDisabled = true
+ UpdraftEvent.FeedbackRequested -> onFeedbackRequested()
+ UpdraftEvent.CloseFeedback -> Unit
+ is UpdraftEvent.Error -> Unit
+ }
+ }
+ }
+
+ updateEvent?.let { event ->
+ UpdateAvailableDialog(
+ event = event,
+ onOpen = { url -> Updraft.openUpdateUrl(url); updateEvent = null },
+ onLater = { updateEvent = null },
+ )
+ }
+ if (showHint) FeedbackHintDialog(onDismiss = { showHint = false })
+ if (showDisabled) FeedbackDisabledDialog(onDismiss = { showDisabled = false })
+}
+```
+
+`updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreen.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.weight
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.ui.drawing.DrawingCanvas
+import com.appswithlove.updraft.ui.drawing.DrawingController
+import com.appswithlove.updraft.ui.resources.Res
+import com.appswithlove.updraft.ui.resources.updraft_button_cancel
+import com.appswithlove.updraft.ui.resources.updraft_button_ok
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun FeedbackScreen(
+ screenshotPng: ByteArray?,
+ onClose: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val state = remember {
+ FeedbackScreenState(
+ send = { screenshot, type, description, email ->
+ Updraft.sendFeedback(screenshot, type, description, email)
+ },
+ scope = scope,
+ )
+ }
+ val drawingController = remember { DrawingController() }
+ var annotating by remember { mutableStateOf(screenshotPng != null) }
+
+ LaunchedEffect(state.result) {
+ if (state.result?.isSuccess == true) onClose()
+ }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ if (annotating && screenshotPng != null) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ Box(modifier = Modifier.weight(1f)) {
+ Image(
+ bitmap = decodePng(screenshotPng),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ )
+ DrawingCanvas(drawingController, modifier = Modifier.fillMaxSize())
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ TextButton(onClick = { drawingController.undo() }, enabled = drawingController.canUndo) { Text("Undo") }
+ TextButton(onClick = { drawingController.redo() }, enabled = drawingController.canRedo) { Text("Redo") }
+ Button(onClick = { annotating = false }) { Text(stringResource(Res.string.updraft_button_ok)) }
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ FeedbackTypeDropdown(selected = state.selectedType, onSelect = { state.selectedType = it })
+ OutlinedTextField(
+ value = state.description,
+ onValueChange = { state.description = it },
+ label = { Text("Description") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = { state.email = it },
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ state.uploadProgress?.let { progress ->
+ LinearProgressIndicator(progress = { progress.toFloat() }, modifier = Modifier.fillMaxWidth())
+ }
+ if (state.result?.isFailure == true) {
+ Text("Upload failed. Please try again.", color = MaterialTheme.colorScheme.error)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = onClose) { Text(stringResource(Res.string.updraft_button_cancel)) }
+ Button(
+ enabled = state.canSend,
+ onClick = {
+ val base = screenshotPng ?: ByteArray(0)
+ val annotated = if (screenshotPng != null) {
+ renderAnnotated(base, drawingController.paths)
+ } else {
+ base
+ }
+ state.sendFeedback(annotated)
+ },
+ ) {
+ if (state.uploadProgress != null) CircularProgressIndicator() else Text("Send")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedbackTypeDropdown(selected: FeedbackType?, onSelect: (FeedbackType) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ OutlinedTextField(
+ value = selected?.name ?: "",
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Type") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.menuAnchor().fillMaxWidth(),
+ )
+ ExposedDropdownMenuDefaults.run {
+ androidx.compose.material3.ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ FeedbackType.entries.forEach { type ->
+ DropdownMenuItem(
+ text = { Text(type.name) },
+ onClick = { onSelect(type); expanded = false },
+ )
+ }
+ }
+ }
+ }
+}
+```
+
+Create expect declarations in `updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.ui.graphics.ImageBitmap
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+
+expect fun decodePng(bytes: ByteArray): ImageBitmap
+expect fun renderAnnotated(base: ByteArray, paths: List): ByteArray
+```
+
+And android actuals in `updraft-ui-compose/src/androidMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.android.kt`:
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.toArgb
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+import java.io.ByteArrayOutputStream
+
+actual fun decodePng(bytes: ByteArray): ImageBitmap =
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size).asImageBitmap()
+
+actual fun renderAnnotated(base: ByteArray, paths: List): ByteArray {
+ val bitmap = BitmapFactory.decodeByteArray(base, 0, base.size)
+ .copy(Bitmap.Config.ARGB_8888, true)
+ val canvas = Canvas(bitmap)
+ paths.forEach { drawn ->
+ val paint = Paint().apply {
+ color = drawn.color.toArgb()
+ strokeWidth = drawn.strokeWidthPx
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ isAntiAlias = true
+ }
+ val path = Path()
+ drawn.points.firstOrNull()?.let { path.moveTo(it.x, it.y) }
+ drawn.points.drop(1).forEach { path.lineTo(it.x, it.y) }
+ canvas.drawPath(path, paint)
+ }
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ return stream.toByteArray()
+}
+```
+
+Note on annotation coordinates: `DrawingCanvas` paths are in on-screen pixel coordinates of the displayed image; the screenshot is captured at the same decor-view size on the same device, so 1:1 mapping holds when the Image fills the screen. Verify visually in Task 13; if the Image is letterboxed, scale paths by (bitmapWidth / canvasWidth) before drawing in `renderAnnotated` — implement that scaling in Task 13's manual-verification fix window if observed.
+
+- [ ] **Step 6: Verify build + tests**
+
+Run: `./gradlew :updraft-ui-compose:testDebugUnitTest :updraft-ui-compose:assemble`
+Expected: BUILD SUCCESSFUL, tests PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add updraft-ui-compose/
+git commit -m "Add Compose feedback screen, dialogs, and event host"
+```
+
+---
+
+### Task 12: Rewrap `updraft-sdk` — delete Views stack
+
+**Files:**
+- Delete: `updraft-sdk/src/main/java/com/rm/freedrawview/` (all files)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/` (all files: FeedbackActivity, FeedbackRootContainer, drawing/, form/)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/` (all files)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/manager/` (all files)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/` (all files)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/api/` (remaining files: UpdraftService, ApiWrapper, ApiException, CountingRequestBody)
+- Delete: `updraft-sdk/src/main/java/com/appswithlove/updraft/Settings.kt`, legacy `LegacyUpdraft.kt`
+- Delete: all layout/menu/drawable resources used only by deleted UI (`updraft-sdk/src/main/res/layout/`, res referenced by FeedbackActivity — check with grep before deleting shared drawables); keep `values*/strings.xml` until confirmed unused, then delete
+- Create: `updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftFeedbackActivity.kt`
+- Create: `updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftOverlayActivity.kt`
+- Create: `updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftAndroid.kt`
+- Modify: `updraft-sdk/src/main/AndroidManifest.xml`
+- Modify: `updraft-sdk/build.gradle.kts`
+
+**Interfaces:**
+- Consumes: `Updraft` object + `UpdraftEvent` (Task 8), `FeedbackScreen` + `UpdraftEventHost` (Task 11).
+- Produces: `updraft-sdk` = `api(project(":updraft-core")) + api(project(":updraft-ui-compose"))` + two host activities. Consumer-facing API is `Updraft.start(UpdraftSettings)` from core — updraft-sdk adds the auto-UI glue only.
+- Auto-UI wiring: `UpdraftAndroid` is an androidx.startup `Initializer` (declared in manifest, depends on `UpdraftCoreInitializer`) that collects `Updraft.events` once `Updraft.start` runs and launches transparent `UpdraftOverlayActivity` for dialog events / `UpdraftFeedbackActivity` for `FeedbackRequested`.
+
+- [ ] **Step 1: Delete legacy stack**
+
+```bash
+git rm -r updraft-sdk/src/main/java/com/rm \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/feedback \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/presentation \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/manager \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/interactor \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/api \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/Settings.kt \
+ updraft-sdk/src/main/java/com/appswithlove/updraft/LegacyUpdraft.kt \
+ updraft-sdk/src/main/res/layout
+```
+
+Then grep remaining res usages (`grep -r "R.drawable\|R.menu\|R.style" updraft-sdk/src/main/java/`) and `git rm` orphaned drawable/menu/style files. Remove Loco block and now-unused deps from `updraft-sdk/build.gradle.kts` (retrofit, adapter-rxjava2, logging-interceptor, ink, lifecycle-extensions, lifecycle-compiler, viewBinding flag, appcompat, material, converter-kotlinx-serialization) and remove `alias(libs.plugins.loco)`. Delete `updraft-sdk/src/main/res/values*/strings.xml` once ui-compose owns strings (Task 9 migrated them).
+
+Resulting `updraft-sdk/build.gradle.kts` dependencies block:
+
+```kotlin
+dependencies {
+ api(project(":updraft-core"))
+ api(project(":updraft-ui-compose"))
+ implementation(libs.core.ktx)
+ implementation(libs.androidx.startup)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation("androidx.activity:activity-compose:1.12.0")
+ implementation("androidx.compose.ui:ui:1.9.4")
+}
+```
+
+(Add `activityCompose = "1.12.0"` / catalog entries instead of string literals: `androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }` — use the catalog form.)
+
+- [ ] **Step 2: Implement host activities + initializer**
+
+`updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftFeedbackActivity.kt`:
+
+```kotlin
+package com.appswithlove.updraft.android
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.ui.feedback.FeedbackScreen
+
+class UpdraftFeedbackActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val screenshot = pendingScreenshot
+ pendingScreenshot = null
+ setContent {
+ FeedbackScreen(
+ screenshotPng = screenshot,
+ onClose = { finish() },
+ )
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Updraft.onFeedbackUiClosed()
+ }
+
+ companion object {
+ private var pendingScreenshot: ByteArray? = null
+
+ fun launch(context: Context, screenshotPng: ByteArray?) {
+ pendingScreenshot = screenshotPng
+ context.startActivity(
+ Intent(context, UpdraftFeedbackActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ (context as? Activity)?.overridePendingTransition(0, 0)
+ }
+ }
+}
+```
+
+(Screenshot passed via static holder, not Intent extra — PNG can exceed the 1 MB Binder limit.)
+
+`updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftOverlayActivity.kt`:
+
+```kotlin
+package com.appswithlove.updraft.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.ui.dialogs.FeedbackDisabledDialog
+import com.appswithlove.updraft.ui.dialogs.FeedbackHintDialog
+import com.appswithlove.updraft.ui.dialogs.UpdateAvailableDialog
+import com.appswithlove.updraft.Updraft
+
+class UpdraftOverlayActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val event = pendingEvent
+ pendingEvent = null
+ if (event == null) {
+ finish()
+ return
+ }
+ setContent {
+ when (event) {
+ is UpdraftEvent.UpdateAvailable -> UpdateAvailableDialog(
+ event = event,
+ onOpen = { url -> Updraft.openUpdateUrl(url); finish() },
+ onLater = { finish() },
+ )
+ UpdraftEvent.ShowFeedbackHint -> FeedbackHintDialog(onDismiss = { finish() })
+ UpdraftEvent.FeedbackDisabled -> FeedbackDisabledDialog(onDismiss = { finish() })
+ else -> finish()
+ }
+ }
+ }
+
+ companion object {
+ private var pendingEvent: UpdraftEvent? = null
+
+ fun launch(context: Context, event: UpdraftEvent) {
+ pendingEvent = event
+ context.startActivity(
+ Intent(context, UpdraftOverlayActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+ }
+}
+```
+
+`updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftAndroid.kt`:
+
+```kotlin
+package com.appswithlove.updraft.android
+
+import android.content.Context
+import androidx.startup.Initializer
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.platform.UpdraftContext
+import com.appswithlove.updraft.platform.UpdraftCoreInitializer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+object UpdraftAndroid {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var started = false
+
+ fun autoWire() {
+ if (started) return
+ started = true
+ scope.launch {
+ // Wait until the host app calls Updraft.start()
+ while (!runCatching { Updraft.settings }.isSuccess) delay(100)
+ Updraft.events.collect { event ->
+ val context = UpdraftContext.application
+ when (event) {
+ is UpdraftEvent.UpdateAvailable,
+ UpdraftEvent.ShowFeedbackHint,
+ UpdraftEvent.FeedbackDisabled,
+ -> UpdraftOverlayActivity.launch(context, event)
+
+ UpdraftEvent.FeedbackRequested ->
+ UpdraftFeedbackActivity.launch(context, Updraft.takePendingScreenshot())
+
+ UpdraftEvent.CloseFeedback, is UpdraftEvent.Error -> Unit
+ }
+ }
+ }
+ }
+}
+
+class UpdraftSdkInitializer : Initializer {
+ override fun create(context: Context): UpdraftAndroid {
+ UpdraftAndroid.autoWire()
+ return UpdraftAndroid
+ }
+
+ override fun dependencies(): List>> =
+ listOf(UpdraftCoreInitializer::class.java)
+}
+```
+
+`Updraft.takePendingScreenshot()` is `internal` in core — since updraft-sdk is a separate module, widen it: in Task 8's `Updraft.kt` change `internal fun takePendingScreenshot()` to `fun takePendingScreenshot()` with KDoc `/** For SDK-internal UI hosts. Returns the screenshot captured for the pending feedback request, once. */`.
+
+`updraft-sdk/src/main/AndroidManifest.xml` — replace activity/provider entries with:
+
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+If `Theme.AppCompat` reference fails without appcompat dep, use `@android:style/Theme.Material.NoActionBar` instead — activities are Compose-only.
+
+- [ ] **Step 3: Build**
+
+Run: `./gradlew :updraft-sdk:assembleDebug`
+Expected: BUILD SUCCESSFUL. `app/` module will now FAIL to compile (uses old API) — expected; fixed in Task 13. Verify with `./gradlew :updraft-sdk:assembleDebug` only.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "Rewrap updraft-sdk as Compose host over core + ui-compose"
+```
+
+---
+
+### Task 13: Sample app on new API + manual verification
+
+**Deviation from spec:** spec proposed renaming `app/` → `sample/` with CMP structure. Deferred to M2 (when `iosApp` arrives and the rename is forced anyway) — M1 keeps `app/` as the Android sample. YAGNI.
+
+**Files:**
+- Modify: `app/src/main/java/com/appswithlove/updraftsdk/App.kt`
+- Modify: `app/src/main/java/com/appswithlove/updraftsdk/MainActivity.kt` (only if it references removed API)
+- Modify: `app/build.gradle.kts` (ensure it depends on `project(":updraft-sdk")` only)
+
+**Interfaces:**
+- Consumes: `Updraft.start(UpdraftSettings)` (Task 8), auto-UI from Task 12.
+
+- [ ] **Step 1: Port App.kt**
+
+```kotlin
+package com.appswithlove.updraftsdk
+
+import android.app.Application
+import com.appswithlove.updraft.LogLevel
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftSettings
+
+class App : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ Updraft.start(
+ UpdraftSettings(
+ appKey = APP_KEY,
+ sdkKey = SDK_KEY,
+ baseUrl = UpdraftSettings.BASE_URL_STAGING,
+ logLevel = LogLevel.Debug,
+ showFeedbackAlert = true,
+ ),
+ )
+ }
+
+ companion object {
+ private const val APP_KEY = ""
+ private const val SDK_KEY = ""
+ }
+}
+```
+
+(Copy the actual key constants from the current `App.kt` before editing.)
+
+- [ ] **Step 2: Full build**
+
+Run: `./gradlew build`
+Expected: BUILD SUCCESSFUL, all module tests pass
+
+- [ ] **Step 3: Manual device verification (checklist)**
+
+Install: `./gradlew :app:installDebug`. On device against staging:
+
+1. Launch → feedback hint dialog appears (first launch).
+2. Shake → screenshot annotate screen; draw; undo/redo; OK → form; select type; send → progress → closes. Verify annotation aligns with drawing (if offset/letterboxed, apply the scaling fix noted in Task 11).
+3. Feedback arrives in Updraft staging dashboard with image + metadata.
+4. With lower build number installed: update dialog on launch; "Open" opens browser.
+5. Second launch: hint dialog NOT shown again in same process; feedback-enabled toggle on dashboard triggers disabled/how-to dialog on next foreground.
+
+Fix any failures before commit; note fixes in commit message.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "Port sample app to Updraft.start API"
+```
+
+---
+
+### Task 14: Version 2.0.0, README, migration guide
+
+**Files:**
+- Modify: `build.gradle.kts` / wherever `version` is set for publishing (check root `build.gradle.kts` and `gradle.properties` for `VERSION_NAME`)
+- Modify: `README.md`
+- Modify: docs spec checklist (tick nothing — just leave)
+
+**Interfaces:** none (docs only).
+
+- [ ] **Step 1: Bump version**
+
+Find current version declaration: `grep -rn "1.1.2\|VERSION_NAME" gradle.properties build.gradle.kts updraft-sdk/`. Set to `2.0.0` for all three published modules.
+
+- [ ] **Step 2: Rewrite README**
+
+Update README sections:
+- Installation: three artifacts table (`updraft-sdk` = Android all-in; `updraft-core` = KMP commonMain logic-only; `updraft-core` + `updraft-ui-compose` = CMP full UI), version `2.0.0`.
+- Setup: `Updraft.start(UpdraftSettings(...))` snippet (replaces `Settings()`/`initialize`).
+- New section "Migrating from 1.x": table mapping `Settings` → `UpdraftSettings` fields, `Updraft.initialize(this, settings)` + `getInstance()?.start()` → `Updraft.start(settings)`, `Settings.LOG_LEVEL_DEBUG` → `LogLevel.Debug`, note Compose runtime now included and `updraft-core`-only escape hatch for size-sensitive Views apps.
+- New section "KMP / Compose Multiplatform": commonMain usage, `setFeedbackUiPresenter` for native-UI apps, `UpdraftEventHost` + `FeedbackScreen` embedding for CMP apps; note iOS targets land in M2.
+- Local Development section: keep, still valid (`publishToMavenLocal`).
+
+- [ ] **Step 3: Build + commit**
+
+Run: `./gradlew build`
+Expected: BUILD SUCCESSFUL
+
+```bash
+git add -A
+git commit -m "Bump to 2.0.0, document KMP/CMP setup and 1.x migration"
+```
+
+---
+
+## Post-plan reminders (do NOT implement in M1)
+
+- Revisit options for Views-based Android apps after M1 (spec "Post-migration reminder").
+- M2 plan: iOS targets/actuals, XCFramework, iosApp sample, deprecate updraft-sdk-ios.
+- Relative-age copy in update dialog (dropped `DateUtils` formatting) — restore with `kotlinx-datetime` in follow-up.
+- CI: `.github/workflows/publish.yml` publishes on release — verify it runs `publishAllPublicationsToMavenCentralRepository`-equivalent for all three modules before first 2.0.0 release.
diff --git a/docs/superpowers/plans/2026-07-20-kmp-migration-m2.md b/docs/superpowers/plans/2026-07-20-kmp-migration-m2.md
new file mode 100644
index 0000000..a700b71
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-20-kmp-migration-m2.md
@@ -0,0 +1,509 @@
+# Updraft SDK KMP Migration — M2 Implementation Plan (iOS)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add iOS support to the KMP SDK — iOS targets + `iosMain` actuals in `updraft-core` and `updraft-ui-compose`, a CMP sample app (androidApp + iosApp), XCFramework export for pure-Swift consumers, and CI so every branch is buildable/testable — fulfilling the remaining acceptance criteria of issue #13. No release in M2; publish workflow is prepared but nothing ships.
+
+**Architecture:** Extends M1 (spec: `docs/superpowers/specs/2026-07-16-kmp-migration-design.md`). All logic already lives in `commonMain` behind expect/actual seams; M2 fills the iOS side: CoreMotion shake, UIWindow screenshot, UIApplication url/foreground, NSUserDefaults store, Skia image codec, `ComposeUIViewController` feedback host. Branch: `feature/kmp-migration-m2` (off `feature/kmp-migration-m1`).
+
+**Tech Stack:** Kotlin 2.2.21 Multiplatform (iosArm64, iosSimulatorArm64, iosX64), Ktor Darwin engine, Compose Multiplatform 1.9.3, Skiko (bundled with CMP) for image codec, XCFramework via kotlin.native cocoapods-free `XCFramework()` helper, GitHub Actions (macos runner).
+
+## Global Constraints
+
+- Kotlin 2.2.21, AGP 8.13.0, compileSdk 36, minSdk 23, JVM 11 — unchanged.
+- iOS deployment target: **iOS 14.0** (matches updraft-sdk-ios floor; set via `it.binaries.framework` config and sample project).
+- Package root stays `com.appswithlove.updraft`. No public API changes to `commonMain` symbols shipped in M1 (additive only).
+- All M1 tests must stay green (`./gradlew build`). iOS-target compile checks: `./gradlew :updraft-core:compileKotlinIosSimulatorArm64 :updraft-ui-compose:compileKotlinIosSimulatorArm64`; iOS unit tests: `./gradlew :updraft-core:iosSimulatorArm64Test`.
+- Shake constants verbatim (shared common math already): threshold 2.7f g via `isShakeGForce`, slop 500 ms.
+- SharedPrefs/NSUserDefaults key names verbatim: suite `feedback_enabled_storage`, key `is_feedback_enabled_property`.
+- NEVER add Co-Authored-By or AI attribution to commits. Commit after every task. NOTHING is released/published in M2 — workflow changes are dry-run-verifiable only (`publishToMavenLocal`).
+- iOS work requires macOS + Xcode; all verify commands in this plan run on this machine (darwin).
+- Apple API adaptation rule: if a Kotlin/Native interop signature differs from the plan's code (argument labels, nullability), adapt minimally and note in the report — behavior contracts and key names stay as written.
+
+---
+
+### Task 1: iOS targets on `updraft-core` + Ktor Darwin
+
+**Files:**
+- Modify: `updraft-core/build.gradle.kts`
+- Modify: `gradle/libs.versions.toml`
+
+**Interfaces:**
+- Produces: `iosArm64()`, `iosSimulatorArm64()`, `iosX64()` targets; `iosMain`/`iosTest` source sets; catalog entry `ktor-client-darwin`. Expect declarations will fail to compile for iOS until Tasks 2–3 add actuals — so this task ONLY adds targets + stub actuals marked `TODO()` is NOT allowed; instead this task adds targets AND the two simplest actuals (Task 2 content is merged here? No —) **this task adds targets and verifies with `linkDebugFrameworkIosSimulatorArm64` deferred**: compile will fail on missing actuals, so the verify step for this task is limited to configuration (`./gradlew :updraft-core:tasks` lists ios targets). Tasks 2–3 restore full compile. Commit together with Task 2 if the intermediate state bothers CI (single-commit allowance).
+
+- [ ] **Step 1: Catalog entry**
+
+`gradle/libs.versions.toml` under `[libraries]`:
+
+```toml
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+```
+
+- [ ] **Step 2: Targets + source sets**
+
+In `updraft-core/build.gradle.kts` inside `kotlin { }` after `androidTarget { ... }`:
+
+```kotlin
+listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ iosX64(),
+).forEach { target ->
+ target.binaries.framework {
+ baseName = "UpdraftCore"
+ isStatic = true
+ }
+}
+```
+
+and inside `sourceSets { }`:
+
+```kotlin
+iosMain.dependencies {
+ implementation(libs.ktor.client.darwin)
+}
+```
+
+- [ ] **Step 3: Verify configuration**
+
+Run: `./gradlew :updraft-core:tasks --group build | grep -i ios`
+Expected: ios compile/link tasks listed. (Full compile deferred to Task 3's verify.)
+
+- [ ] **Step 4: Commit** (allowed to fold into Task 2's commit if compile-breaking intermediate state)
+
+```bash
+git add gradle/libs.versions.toml updraft-core/build.gradle.kts
+git commit -m "Add iOS targets and Ktor Darwin engine to updraft-core"
+```
+
+---
+
+### Task 2: iosMain actuals — `KeyValueStore` + `AppInfo`
+
+**Files:**
+- Create: `updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.ios.kt`
+- Create: `updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/AppInfo.ios.kt`
+
+**Interfaces:**
+- Consumes: `KeyValueStore` interface, `AppInfo` class, expect fns (M1 Task 4).
+- Produces: NSUserDefaults-suite-backed store; AppInfo from NSBundle/UIDevice.
+
+- [ ] **Step 1: Implement**
+
+`KeyValueStore.ios.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import platform.Foundation.NSUserDefaults
+
+private class UserDefaultsKeyValueStore(private val defaults: NSUserDefaults) : KeyValueStore {
+ override fun getBoolean(key: String, default: Boolean): Boolean =
+ if (defaults.objectForKey(key) != null) defaults.boolForKey(key) else default
+
+ override fun putBoolean(key: String, value: Boolean) {
+ defaults.setBool(value, key)
+ }
+}
+
+actual fun createKeyValueStore(name: String): KeyValueStore =
+ UserDefaultsKeyValueStore(NSUserDefaults(suiteName = name))
+```
+
+`AppInfo.ios.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import platform.Foundation.NSBundle
+import platform.UIKit.UIDevice
+
+actual fun currentAppInfo(): AppInfo {
+ val info = NSBundle.mainBundle.infoDictionary
+ val versionCode = (info?.get("CFBundleVersion") as? String)?.toLongOrNull() ?: -1L
+ val versionName = info?.get("CFBundleShortVersionString") as? String ?: ""
+ val device = UIDevice.currentDevice
+ return AppInfo(
+ versionCode = versionCode,
+ versionName = versionName,
+ systemVersion = device.systemVersion,
+ deviceName = device.model,
+ deviceUuid = device.identifierForVendor?.UUIDString ?: "",
+ )
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add updraft-core/src/iosMain/
+git commit -m "Add iOS actuals for KeyValueStore and AppInfo"
+```
+
+---
+
+### Task 3: iosMain actuals — Platform seams (shake, screenshot, url, foreground)
+
+**Files:**
+- Create: `updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/Platform.ios.kt`
+- Test: `updraft-core/src/iosTest/kotlin/com/appswithlove/updraft/platform/IosSmokeTest.kt`
+
+**Interfaces:**
+- Consumes: `ShakeDetector`/`ScreenshotGrabber`/`AppForegroundObserver` interfaces + `isShakeGForce` (M1 Task 7).
+- Produces: CoreMotion-based shake (reuses common math, 60 Hz updates, 500 ms slop, one-shot `enabled` flag re-armed via `Updraft.onFeedbackUiClosed()` exactly like Android), key-window screenshot → PNG bytes, `UIApplication.openURL`, foreground observer via `NSNotificationCenter` (`UIApplicationDidBecomeActiveNotification` / `UIApplicationDidEnterBackgroundNotification`).
+
+- [ ] **Step 1: Implement**
+
+`Platform.ios.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.useContents
+import platform.CoreMotion.CMMotionManager
+import platform.Foundation.NSDate
+import platform.Foundation.NSNotificationCenter
+import platform.Foundation.NSOperationQueue
+import platform.Foundation.NSURL
+import platform.Foundation.timeIntervalSince1970
+import platform.UIKit.UIApplication
+import platform.UIKit.UIApplicationDidBecomeActiveNotification
+import platform.UIKit.UIApplicationDidEnterBackgroundNotification
+import platform.UIKit.UIGraphicsImageRenderer
+import platform.UIKit.UIImagePNGRepresentation
+import platform.UIKit.UIWindow
+import platform.posix.memcpy
+
+private class IosShakeDetector(private val onShake: () -> Unit) : ShakeDetector {
+ private val motionManager = CMMotionManager()
+ private var shakeTimestamp = 0.0
+ private var enabled = true
+
+ override fun start() {
+ if (!motionManager.accelerometerAvailable) return
+ motionManager.accelerometerUpdateInterval = 1.0 / 60.0
+ motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.mainQueue) { data, _ ->
+ val acceleration = data?.acceleration ?: return@startAccelerometerUpdatesToQueue
+ acceleration.useContents {
+ // CoreMotion reports in g units already; gravity divisor = 1.0
+ if (!isShakeGForce(x.toFloat(), y.toFloat(), z.toFloat(), 1.0f)) return@useContents
+ val now = NSDate().timeIntervalSince1970
+ if (shakeTimestamp + SHAKE_SLOP_SECONDS > now) return@useContents
+ shakeTimestamp = now
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+ }
+ }
+
+ override fun stop() {
+ motionManager.stopAccelerometerUpdates()
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ this.enabled = enabled
+ }
+
+ companion object {
+ private const val SHAKE_SLOP_SECONDS = 0.5
+ }
+}
+
+actual fun createShakeDetector(onShake: () -> Unit): ShakeDetector = IosShakeDetector(onShake)
+
+@OptIn(ExperimentalForeignApi::class)
+private class IosScreenshotGrabber : ScreenshotGrabber {
+ override fun capturePng(): ByteArray? {
+ val window = keyWindow() ?: return null
+ val bounds = window.bounds
+ val renderer = UIGraphicsImageRenderer(bounds = bounds)
+ val image = renderer.imageWithActions { _ ->
+ window.drawViewHierarchyInRect(bounds, afterScreenUpdates = false)
+ }
+ val nsData = UIImagePNGRepresentation(image) ?: return null
+ val length = nsData.length.toInt()
+ if (length == 0) return null
+ val bytes = ByteArray(length)
+ bytes.usePinned { pinned ->
+ memcpy(pinned.addressOf(0), nsData.bytes, nsData.length)
+ }
+ return bytes
+ }
+
+ private fun keyWindow(): UIWindow? =
+ UIApplication.sharedApplication.windows
+ .filterIsInstance()
+ .firstOrNull { it.isKeyWindow() }
+}
+
+actual fun createScreenshotGrabber(): ScreenshotGrabber = IosScreenshotGrabber()
+
+actual fun openUrl(url: String) {
+ val nsUrl = NSURL.URLWithString(url) ?: return
+ UIApplication.sharedApplication.openURL(nsUrl, options = emptyMap(), completionHandler = null)
+}
+
+private class IosForegroundObserver(
+ private val onForeground: () -> Unit,
+ private val onBackground: () -> Unit,
+) : AppForegroundObserver {
+ override fun start() {
+ val center = NSNotificationCenter.defaultCenter
+ center.addObserverForName(UIApplicationDidBecomeActiveNotification, `object` = null, queue = NSOperationQueue.mainQueue) { onForeground() }
+ center.addObserverForName(UIApplicationDidEnterBackgroundNotification, `object` = null, queue = NSOperationQueue.mainQueue) { onBackground() }
+ }
+}
+
+actual fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver =
+ IosForegroundObserver(onForeground, onBackground)
+```
+
+(Missing imports like `usePinned`/`addressOf` from `kotlinx.cinterop` — add as compiler demands; interop-label adaptation rule applies.)
+
+- [ ] **Step 2: iOS smoke test**
+
+`IosSmokeTest.kt`:
+
+```kotlin
+package com.appswithlove.updraft.platform
+
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class IosSmokeTest {
+ @Test
+ fun keyValueStore_roundTrips() {
+ val store = createKeyValueStore("updraft_test_suite")
+ store.putBoolean("k", true)
+ assertTrue(store.getBoolean("k", false))
+ }
+
+ @Test
+ fun appInfo_populates() {
+ val info = currentAppInfo()
+ assertNotNull(info.systemVersion)
+ }
+}
+```
+
+- [ ] **Step 3: Verify — full iOS compile + iOS tests + Android regression**
+
+Run: `./gradlew :updraft-core:compileKotlinIosSimulatorArm64 :updraft-core:iosSimulatorArm64Test :updraft-core:testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests pass (common tests now also run on iOS runner).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add updraft-core/
+git commit -m "Add iOS actuals for shake, screenshot, url, and foreground observer"
+```
+
+---
+
+### Task 4: iOS targets on `updraft-ui-compose` + Skia image codec actuals
+
+**Files:**
+- Modify: `updraft-ui-compose/build.gradle.kts` (same 3 ios targets, framework baseName `UpdraftUI`)
+- Create: `updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.ios.kt`
+
+**Interfaces:**
+- Consumes: `expect fun decodePng(bytes): ImageBitmap`, `expect fun renderAnnotated(base, paths, canvasSize): ByteArray`, `mapToBitmapSpace` (M1 Task 11 + final-fix wave — CHECK actual current signature in `updraft-ui-compose/src/commonMain/.../ImageCodec.kt` before writing; the fix wave changed `renderAnnotated` to take canvas size).
+- Produces: Skia-based actuals usable from CMP on iOS.
+
+- [ ] **Step 1: Targets in build file** (mirror Task 1's block, baseName `"UpdraftUI"`).
+
+- [ ] **Step 2: Implement Skia actuals**
+
+`ImageCodec.ios.kt` (adapt names to the CURRENT common expect signatures):
+
+```kotlin
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import androidx.compose.ui.unit.IntSize
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+import org.jetbrains.skia.Canvas
+import org.jetbrains.skia.EncodedImageFormat
+import org.jetbrains.skia.Image
+import org.jetbrains.skia.Paint
+import org.jetbrains.skia.PaintMode
+import org.jetbrains.skia.PaintStrokeCap
+import org.jetbrains.skia.PaintStrokeJoin
+import org.jetbrains.skia.Path
+import org.jetbrains.skia.Surface
+
+actual fun decodePng(bytes: ByteArray): ImageBitmap =
+ Image.makeFromEncoded(bytes).toComposeImageBitmap()
+
+actual fun renderAnnotated(base: ByteArray, paths: List, canvasSize: IntSize): ByteArray {
+ val baseImage = Image.makeFromEncoded(base)
+ val surface = Surface.makeRasterN32Premul(baseImage.width, baseImage.height)
+ val canvas: Canvas = surface.canvas
+ canvas.drawImage(baseImage, 0f, 0f)
+ paths.forEach { drawn ->
+ val paint = Paint().apply {
+ color = drawn.color.hashCode() // REPLACE: use drawn.color.toArgb() — androidx.compose.ui.graphics.toArgb works in common
+ mode = PaintMode.STROKE
+ strokeWidth = drawn.strokeWidthPx
+ strokeCap = PaintStrokeCap.ROUND
+ strokeJoin = PaintStrokeJoin.ROUND
+ isAntiAlias = true
+ }
+ val path = Path()
+ val mapped = drawn.points.map { mapToBitmapSpace(it, canvasSize, baseImage.width, baseImage.height) }
+ mapped.firstOrNull()?.let { path.moveTo(it.x, it.y) }
+ mapped.drop(1).forEach { path.lineTo(it.x, it.y) }
+ canvas.drawPath(path, paint)
+ }
+ return surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)!!.bytes
+}
+```
+
+IMPORTANT: mirror whatever coordinate-mapping the Android actual does today (read `ImageCodec.android.kt` first) so behavior is identical; `color.toArgb()` (androidx.compose.ui.graphics) is the correct color conversion, the hashCode line above is a placeholder marker that MUST be replaced.
+
+- [ ] **Step 3: Verify**
+
+Run: `./gradlew :updraft-ui-compose:compileKotlinIosSimulatorArm64 :updraft-ui-compose:iosSimulatorArm64Test :updraft-ui-compose:testDebugUnitTest`
+Expected: BUILD SUCCESSFUL; existing `ImageCodecTest` mapping tests now also pass on iOS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add updraft-ui-compose/
+git commit -m "Add iOS targets and Skia image codec to updraft-ui-compose"
+```
+
+---
+
+### Task 5: iOS feedback host — `UpdraftFeedbackViewController` + auto-wire
+
+**Files:**
+- Create: `updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/ios/UpdraftIos.kt`
+
+**Interfaces:**
+- Consumes: `FeedbackScreen(screenshotPng, onClose)`, `UpdraftEventHost(events, onFeedbackRequested)`, `Updraft.events/takePendingScreenshot/onFeedbackUiClosed`.
+- Produces:
+
+```kotlin
+// Swift-callable factory: present this from any UIViewController
+fun UpdraftFeedbackViewController(screenshotPng: ByteArray?, onClose: () -> Unit): UIViewController
+
+// Optional auto mode (parity with Android auto-UI): call once after Updraft.start()
+fun UpdraftIos.autoWire() // collects Updraft.events on Dispatchers.Main:
+ // dialog events -> presents ComposeUIViewController hosting UpdraftEventHost
+ // FeedbackRequested -> presents UpdraftFeedbackViewController(takePendingScreenshot())
+ // presentation via topmost view controller of key window; onClose -> dismiss + onFeedbackUiClosed()
+```
+
+Implementation via `androidx.compose.ui.window.ComposeUIViewController { ... }`. Topmost-VC helper: walk `presentedViewController` chain from key window's `rootViewController`. Keep the collector in a `MainScope()` singleton; idempotent like Android's `autoWire`.
+
+- [ ] **Step 1: Implement** (structure above; exact modal style `UIModalPresentationFullScreen` for feedback, `.OverFullScreen` for dialogs).
+- [ ] **Step 2: Verify** `./gradlew :updraft-ui-compose:compileKotlinIosSimulatorArm64`
+- [ ] **Step 3: Commit** `git commit -m "Add iOS feedback view controller and event auto-wiring"`
+
+---
+
+### Task 6: Sample restructure — `sample/` CMP app (androidApp + iosApp)
+
+**Files:**
+- Create: `sample/composeApp/build.gradle.kts` (KMP: androidTarget + ios targets; commonMain deps: core + ui-compose + compose material3; android application config copied from current `app/` incl. updraft upload plugin block)
+- Create: `sample/composeApp/src/commonMain/kotlin/com/appswithlove/updraftsdk/App.kt` (shared `@Composable fun SampleApp()` — simple screen: app title, "Check for update" button → `Updraft.checkForUpdate()`, "Give feedback" button → `Updraft.showFeedback()`)
+- Create: `sample/composeApp/src/androidMain/` (Application class calling `Updraft.start(...)` staging settings, MainActivity hosting `SampleApp()`, manifest — port from current `app/`)
+- Create: `sample/composeApp/src/iosMain/kotlin/.../MainViewController.kt` (`fun MainViewController() = ComposeUIViewController { SampleApp() }` + `Updraft.start(...)` + `UpdraftIos.autoWire()` in an init fn called from Swift)
+- Create: `sample/iosApp/` — minimal Xcode project (iosApp.xcodeproj, `iOSApp.swift` with `@main` SwiftUI App wrapping `MainViewController()` via `UIViewControllerRepresentable`, Info.plist). Generate with the standard KMP wizard layout; embed `composeApp` framework via the Gradle-embedAndSign build phase.
+- Delete: `app/` (git rm -r) after sample builds
+- Modify: `settings.gradle.kts` (remove `:app`, add `:sample:composeApp`)
+
+**Steps:**
+- [ ] Build shared module first: `./gradlew :sample:composeApp:assembleDebug`
+- [ ] Android sample installs + runs (manual smoke or `:sample:composeApp:installDebug` if device attached; otherwise assemble is the gate)
+- [ ] iOS sample builds: `xcodebuild -project sample/iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -configuration Debug build` (or `-destination 'platform=iOS Simulator,name=iPhone 16'`)
+- [ ] `git rm -r app/` + settings update; full `./gradlew build` green
+- [ ] Commit: `git commit -m "Replace app/ with CMP sample (androidApp + iosApp)"` (split into 2 commits if cleaner: add sample, then remove app)
+
+---
+
+### Task 7: XCFramework export for pure-Swift consumers
+
+**Files:**
+- Modify: `updraft-core/build.gradle.kts`, `updraft-ui-compose/build.gradle.kts`
+
+**Steps:**
+- [ ] Use `kotlin("native.cocoapods")`-free approach: `import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework`; create one umbrella XCFramework from `updraft-core` (name `UpdraftCore`) — framework config `export(project(":updraft-core"))` not needed for core itself; for a combined Swift-consumable SDK later evaluate an umbrella module, out of scope now.
+
+```kotlin
+// updraft-core/build.gradle.kts, inside kotlin { }
+val xcf = XCFramework("UpdraftCore")
+listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { target ->
+ target.binaries.framework {
+ baseName = "UpdraftCore"
+ isStatic = true
+ xcf.add(this)
+ }
+}
+```
+
+(Replace the Task-1 target block with this — don't declare targets twice.)
+- [ ] Verify: `./gradlew :updraft-core:assembleUpdraftCoreXCFramework` → `updraft-core/build/XCFrameworks/release/UpdraftCore.xcframework` exists.
+- [ ] Document in README: Swift integration = drag XCFramework / SPM binary target (distribution mechanics = release-time decision, out of M2 scope; note it).
+- [ ] Commit: `git commit -m "Add XCFramework export for updraft-core"`
+
+---
+
+### Task 8: CI workflow — every branch buildable/testable
+
+**Files:**
+- Create: `.github/workflows/ci.yml`
+
+**Steps:**
+- [ ] Workflow: on `push` to `feature/**` + `pull_request` to `main`. Job 1 `android` (ubuntu-latest): JDK 17, gradle cache, `./gradlew build` (all module unit tests). Job 2 `ios` (macos-14): JDK 17, `./gradlew :updraft-core:iosSimulatorArm64Test :updraft-ui-compose:iosSimulatorArm64Test :updraft-core:assembleUpdraftCoreXCFramework` + `xcodebuild` of sample/iosApp (simulator, no signing: `CODE_SIGNING_ALLOWED=NO`).
+- [ ] Verify locally what's verifiable: `./gradlew build` green; YAML lint (actionlint if available, else careful review). Real verification = push to the M2 branch and watch the run.
+- [ ] Commit + push, confirm both jobs green on GitHub before proceeding: `gh run watch`.
+- [ ] Commit: `git commit -m "Add CI workflow for Android and iOS builds"`
+
+---
+
+### Task 9: Prepare publish workflow for all modules (NO release)
+
+**Files:**
+- Modify: `.github/workflows/publish.yml`
+
+**Steps:**
+- [ ] Change the publish step to publish root-level all modules: `./gradlew publishAllPublicationsToMavenCentralRepository` (vanniktech plugin task name — verify against plugin 0.34.0 docs/tasks: `./gradlew tasks --all | grep -i publish`) so `updraft-core`, `updraft-ui-compose`, `updraft-sdk` all ship together. KMP modules publish per-target artifacts automatically (android + ios klibs + metadata) — macos runner REQUIRED for ios artifacts: switch workflow to `runs-on: macos-14`.
+- [ ] Reconcile trigger with README Release section (workflow reality wins — document whichever is true).
+- [ ] Dry-run verify: `./gradlew publishToMavenLocal` → `~/.m2/repository/com/appswithlove/updraft/` contains updraft-core (incl. `-iosarm64`, `-iossimulatorarm64`, `-iosx64` variants), updraft-ui-compose, updraft-sdk, all 2.0.0. DO NOT run any remote publish task.
+- [ ] Commit: `git commit -m "Publish all modules from macos runner (release still manual)"`
+
+---
+
+### Task 10: Docs — iOS setup, Swift integration, updraft-sdk-ios deprecation note
+
+**Files:**
+- Modify: `README.md`, `docs/kmp-migration-m1-status.md` (mark M2 items), `docs/superpowers/specs/2026-07-16-kmp-migration-design.md` (tick M2 milestone)
+
+**Steps:**
+- [ ] README: replace "iOS = M2, not yet available" notes with real setup: KMP commonMain usage now covers iOS; CMP apps get `UpdraftIos.autoWire()`; Swift apps section (XCFramework, `UpdraftFeedbackViewController` from Swift, `Updraft.start` from Swift via the framework); iOS 14+ requirement; note that permission-free APIs only (CoreMotion accelerometer needs no permission).
+- [ ] README + issue: state that updraft-sdk-ios is superseded once 2.0.0 ships; actual deprecation (repo archive, final README banner there) is a release-time action, listed in status doc.
+- [ ] Update `docs/kmp-migration-m1-status.md`: retitle relevant section or add `## M2 status` — sample keys still needed for device pass on BOTH platforms.
+- [ ] Full build green; commit: `git commit -m "Document iOS setup and Swift integration"`
+
+---
+
+## Verification gate for M2 completion (before any merge)
+
+1. CI green on `feature/kmp-migration-m2` (android + ios jobs).
+2. `./gradlew build` + both `iosSimulatorArm64Test` suites green locally.
+3. Sample iosApp runs in simulator: start → hint dialog; simulator shake (Device > Shake) → feedback screen appears; annotate; form renders. (Network calls need real keys — UI flow verifiable without.)
+4. Android sample unchanged behavior (M1 checklist).
+5. Nothing published anywhere; branches `feature/kmp-migration-m1` and `feature/kmp-migration-m2` both on origin.
+
+## Explicit non-goals in M2
+
+- No Maven Central / GitHub release, no repo archive of updraft-sdk-ios (release-time actions).
+- No SPM package manifest polish (distribution decision deferred).
+- M1 follow-up list (stroke width, rotation state, races) — separate wave, not M2.
diff --git a/docs/superpowers/specs/2026-07-16-kmp-migration-design.md b/docs/superpowers/specs/2026-07-16-kmp-migration-design.md
new file mode 100644
index 0000000..ebda3c0
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-16-kmp-migration-design.md
@@ -0,0 +1,136 @@
+# Updraft SDK — KMP/CMP Migration Design
+
+Date: 2026-07-16
+Issue: [#13](https://github.com/appswithlove/updraft-sdk-android/issues/13)
+
+## Goal
+
+One SDK codebase serving three consumer types:
+
+1. Android-only native apps (existing consumers)
+2. KMP apps without shared UI (shared logic, native feedback UI per platform)
+3. CMP apps (fully shared, including feedback UI)
+
+The KMP SDK replaces `updraft-sdk-ios` once iOS support ships (M2).
+
+## Decisions
+
+- **Layered artifacts** (core / compose UI / Android wrapper) — not one fat artifact, not separate SDKs.
+- **Compose-first UI**: existing Views UI (Fragments, ViewBinding, `FreeDrawView`) is deleted and replaced by a Compose Multiplatform UI rendered via `ComposeView` on Android. Accepted cost: ~2 MB (release, R8) for Views-only consumer apps; ~0 for Compose apps. Escape hatch: `updraft-core` + own UI (~300 KB).
+- **In-place migration** of this repo. Fresh-repo/rename decision deferred (moving later is a plain push, no lock-in).
+- **M1 scope**: core + CMP UI with Android target only. iOS actuals in M2.
+
+## Module architecture
+
+```
+updraft-sdk-android/ (repo)
+├── updraft-core/ KMP library
+│ ├── commonMain/ UpdraftSettings, Updraft entry, interactors,
+│ │ Ktor api client + models, version comparison,
+│ │ expect: ScreenshotProvider, ShakeDetector,
+│ │ UpdateInstaller, KeyValueStore, AppInfo
+│ ├── androidMain/ actuals: SensorManager shake, Activity screenshot,
+│ │ APK/link install, SharedPrefs, androidx.startup
+│ │ init, current-activity tracking
+│ └── iosMain/ (M2) motionEnded shake, UIWindow screenshot,
+│ itms-services install, NSUserDefaults
+├── updraft-ui-compose/ CMP module
+│ └── commonMain/ FeedbackForm, DrawingCanvas (FreeDrawView rewrite
+│ in Compose Canvas + pointerInput), theme,
+│ strings via compose-resources (en/de)
+├── updraft-sdk/ Android artifact, existing Maven coords
+│ └── thin wrapper: core + ui-compose, one Activity hosting ComposeView.
+│ Views UI, Fragments, ViewBinding, FreeDrawView, ink lib deleted.
+└── sample/ replaces app/ — CMP sample (androidApp in M1,
+ iosApp in M2)
+```
+
+Published artifacts: `updraft-core`, `updraft-ui-compose`, `updraft-sdk`
+(coords `com.appswithlove.updraft:updraft-sdk` unchanged).
+
+Tech swaps in core: Retrofit → Ktor (OkHttp engine on Android), RxJava2 → coroutines/Flow, SharedPrefs → `KeyValueStore` expect/actual, Loco gradle plugin → compose-resources.
+
+## Public API (commonMain)
+
+```kotlin
+class UpdraftSettings(
+ val appKey: String,
+ val sdkKey: String,
+ val baseUrl: String = PROD,
+ val logLevel: LogLevel = Error,
+ val showFeedbackAlert: Boolean = true,
+ val feedbackEnabled: Boolean = true,
+)
+
+object Updraft {
+ fun start(settings: UpdraftSettings)
+ fun checkForUpdate()
+ fun showFeedback()
+ fun setFeedbackUiPresenter(presenter: FeedbackUiPresenter) // KMP-no-UI hook
+ fun sendFeedback(screenshot: ByteArray?, text: String, type: FeedbackType)
+ val events: Flow // UpdateAvailable, FeedbackSent, Error
+}
+```
+
+Android `Context` acquired internally via androidx.startup — no context parameter in common API.
+
+### Consumer wiring
+
+- **Android-only**: `updraft-sdk` dep, `Updraft.start(settings)` in `Application.onCreate`. Shake→feedback and update dialog automatic, as today.
+- **KMP no shared UI**: `updraft-core` in commonMain. `setFeedbackUiPresenter` for own UI; submit via `sendFeedback`.
+- **CMP**: core + `updraft-ui-compose`. Default: SDK opens own host (Activity / UIViewController) with the Compose UI. Also exposes embeddable `UpdraftFeedbackDialog()` composable.
+
+### Flows
+
+- **Update**: start → Ktor `getLastVersion` → version compare (micro-version logic ported as-is) → `UpdateAvailable` event → default dialog or app-handled → `UpdateInstaller.install(url)`.
+- **Feedback**: shake (expect/actual) → screenshot capture → Compose form + drawing canvas → multipart upload via Ktor (`onUpload` progress → Flow).
+
+## Migration steps (in-place)
+
+1. Gradle: KMP + CMP plugins in version catalog; create `updraft-core` (android target first); `git mv` non-UI classes; split commonMain/androidMain as classes lose Android deps.
+2. Port order in core: models/Settings → version compare → Ktor ApiWrapper (remove Retrofit + RxJava2) → interactors as suspend/Flow → managers behind expect/actual.
+3. `updraft-ui-compose`: FeedbackForm + DrawingCanvas fresh in CMP; port FreeDrawView logic (paths, undo/redo, color/width) 1:1.
+4. `updraft-sdk`: gut to wrapper; delete Views stack.
+5. `app/` → `sample/` (CMP structure).
+6. M2: ios targets + actuals, XCFramework export (SPM/CocoaPods), iosApp sample, deprecate `updraft-sdk-ios`.
+
+## Compatibility
+
+- `updraft-sdk` 2.0.0 = breaking (API rename `Settings`→`UpdraftSettings`, no `initialize(context, …)`, Compose dependency). Major bump.
+- 1.x branch frozen, hotfixes only.
+- README migration table 1.x → 2.0.
+
+## Testing
+
+- commonTest: version comparison (existing cases + micro-version), interactors with Ktor MockEngine, settings validation. Kotest + Turbine.
+- ui-compose: Compose UI tests (form validation, drawing) via Robolectric on Android.
+- Manual: sample app on device — shake, annotate, submit, update dialog against staging.
+
+## Milestones
+
+- **M1** (releasable): core KMP (android target) + ui-compose + rewrapped `updraft-sdk` 2.0.0. Android feature parity on new stack.
+- **M2** (delivered, see [M2 status](../kmp-migration-m1-status.md#m2-status-2026-07-21)): iOS targets + actuals + XCFramework + iosApp sample; replaces `updraft-sdk-ios`.
+- **M3** (optional): fresh-repo/rename decision, SPM polish, further targets.
+
+## Risks
+
+- Compose runtime forced on `updraft-sdk` consumers (accepted, see Decisions).
+- Drawing canvas rewrite fidelity — mitigate by porting FreeDraw math 1:1.
+- Loco → compose-resources string pipeline swap (en/de carried over).
+- Multipart upload progress in Ktor — supported via `onUpload`.
+
+## Post-migration reminder
+
+- [ ] **After M1 ships: revisit options for Views-based Android apps.** Evaluate whether the ~2 MB Compose cost needs a dedicated answer — e.g. documented `updraft-core`-only recipe with a minimal Views feedback screen, a community `updraft-ui-views` artifact, or confirmation that no consumer actually needs it.
+
+## Size impact (reference)
+
+| Consumer | Deps | Added size (release, R8) |
+|---|---|---|
+| Android app already using Compose | `updraft-sdk` | ~100–300 KB |
+| Views-only Android app | `updraft-sdk` | ~2 MB |
+| Views-only app, minimal | `updraft-core` + own UI | ~200–400 KB |
+| KMP no shared UI | `updraft-core` | as above per platform |
+| CMP app | core + ui-compose | ~0 marginal |
+
+Precedent: Sentry, RevenueCat, Firebase, Coil all ship core + optional UI artifacts; Instabug/Shake bake UI in at 3–5 MB.
diff --git a/gradle.properties b/gradle.properties
index 4c85cdd..baa535b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,7 +6,8 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx4g
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 65b81ec..3f832bc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,43 +5,59 @@ appcompat = "1.7.1"
constraintlayout = "2.2.1"
coreKtx = "1.17.0"
dokka = "2.1.0"
-ink = "1.0.0"
androidxStartup = "1.1.1"
+activityCompose = "1.12.0"
+composeUi = "1.9.4"
kotlinxSerializationJson = "1.9.0"
lifecycleExtensions = "2.2.0"
lifecycleViewmodelKtx = "2.9.4"
converterKotlinxSerialization = "3.0.0"
loco = "0.4.1"
-loggingInterceptor = "4.12.0"
material = "1.13.0"
-retrofit = "2.9.0"
updraft = "2.3.0"
maven-publish = "0.34.0"
coveralls = "2.4.0"
+composeMultiplatform = "1.9.3"
+ktor = "3.3.3"
+coroutines = "1.10.2"
+turbine = "1.2.1"
+lifecycleProcess = "2.9.4"
[libraries]
-adapter-rxjava2 = { module = "com.squareup.retrofit2:adapter-rxjava2", version.ref = "retrofit" }
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "converterKotlinxSerialization" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
-ink = { module = "com.simplify:ink", version.ref = "ink" }
kotlin-stdlib-jdk7 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lifecycle-compiler = { module = "androidx.lifecycle:lifecycle-compiler", version.ref = "lifecycleExtensions" }
lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycleExtensions" }
lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycleExtensions" }
lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
material = { module = "com.google.android.material:material", version.ref = "material" }
-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidxStartup" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "composeUi" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleProcess" }
[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }
android-library = { id = "com.android.library", version.ref = "gradle" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" }
coveralls = { id = "com.github.kt3k.coveralls", version.ref = "coveralls" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
diff --git a/app/.gitignore b/sample/composeApp/.gitignore
similarity index 100%
rename from app/.gitignore
rename to sample/composeApp/.gitignore
diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts
new file mode 100644
index 0000000..326cd1b
--- /dev/null
+++ b/sample/composeApp/build.gradle.kts
@@ -0,0 +1,121 @@
+import java.util.Properties
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.updraft)
+}
+
+val localProperties = Properties().apply {
+ val file = rootProject.file("local.properties")
+ if (file.exists()) file.inputStream().use { load(it) }
+}
+
+val generateSampleKeys = tasks.register("generateSampleKeys") {
+ val appKeyAndroid = localProperties.getProperty("updraft.appKey.android") ?: ""
+ val appKeyIos = localProperties.getProperty("updraft.appKey.ios") ?: ""
+ val sdkKey = localProperties.getProperty("updraft.sdkKey") ?: ""
+ val outputDir = layout.buildDirectory.dir("generated/sampleKeys/kotlin")
+ inputs.property("appKeyAndroid", appKeyAndroid)
+ inputs.property("appKeyIos", appKeyIos)
+ inputs.property("sdkKey", sdkKey)
+ outputs.dir(outputDir)
+ doLast {
+ val packageDir = outputDir.get().asFile.resolve("com/appswithlove/updraftsdk")
+ packageDir.mkdirs()
+ packageDir.resolve("SampleKeys.kt").writeText(
+ """
+ package com.appswithlove.updraftsdk
+
+ object SampleKeys {
+ const val APP_KEY_ANDROID = "$appKeyAndroid"
+ const val APP_KEY_IOS = "$appKeyIos"
+ const val SDK_KEY = "$sdkKey"
+ }
+ """.trimIndent() + "\n"
+ )
+ }
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+ }
+
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ iosX64(),
+ ).forEach { target ->
+ target.binaries.framework {
+ baseName = "SampleApp"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain {
+ kotlin.srcDir(generateSampleKeys)
+ }
+ commonMain.dependencies {
+ implementation(project(":updraft-core"))
+ implementation(project(":updraft-ui-compose"))
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ }
+ androidMain.dependencies {
+ implementation(project(":updraft-sdk"))
+ implementation(libs.androidx.activity.compose)
+ }
+ }
+}
+
+android {
+ namespace = "com.appswithlove.updraftsdk"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.appswithlove.updraftsdk"
+ minSdk = 23
+ targetSdk = 36
+ versionCode = 6
+ versionName = "1.5"
+ }
+
+ signingConfigs {
+ create("release") {
+ storeFile = file("updraft_test.jks")
+ storePassword = "appswithlove"
+ keyAlias = "release"
+ keyPassword = "appswithlove"
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("release")
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+val updraftUploadUrl: String = findProperty("updraft_uploadUrl") as? String ?: ""
+updraft {
+ urls = mapOf("Release" to listOf(updraftUploadUrl))
+}
diff --git a/app/gradle.properties b/sample/composeApp/gradle.properties
similarity index 100%
rename from app/gradle.properties
rename to sample/composeApp/gradle.properties
diff --git a/app/proguard-rules.pro b/sample/composeApp/proguard-rules.pro
similarity index 100%
rename from app/proguard-rules.pro
rename to sample/composeApp/proguard-rules.pro
diff --git a/app/src/main/AndroidManifest.xml b/sample/composeApp/src/androidMain/AndroidManifest.xml
similarity index 78%
rename from app/src/main/AndroidManifest.xml
rename to sample/composeApp/src/androidMain/AndroidManifest.xml
index ee8ac95..16694c7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/sample/composeApp/src/androidMain/AndroidManifest.xml
@@ -1,6 +1,5 @@
-
+
+ android:theme="@android:style/Theme.Material.Light.NoActionBar">
@@ -19,4 +18,4 @@
-
\ No newline at end of file
+
diff --git a/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/App.kt b/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/App.kt
new file mode 100644
index 0000000..6d680e3
--- /dev/null
+++ b/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/App.kt
@@ -0,0 +1,21 @@
+package com.appswithlove.updraftsdk
+
+import android.app.Application
+import com.appswithlove.updraft.LogLevel
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftSettings
+
+class App : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ Updraft.start(
+ UpdraftSettings(
+ appKey = SampleKeys.APP_KEY_ANDROID,
+ sdkKey = SampleKeys.SDK_KEY,
+ logLevel = LogLevel.Debug,
+ showFeedbackAlert = true,
+ ),
+ )
+ }
+}
diff --git a/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/MainActivity.kt b/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/MainActivity.kt
new file mode 100644
index 0000000..a87a82a
--- /dev/null
+++ b/sample/composeApp/src/androidMain/kotlin/com/appswithlove/updraftsdk/MainActivity.kt
@@ -0,0 +1,16 @@
+package com.appswithlove.updraftsdk
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+class MainActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ SampleApp()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/sample/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_foreground.xml
rename to sample/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
rename to sample/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
rename to sample/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/sample/composeApp/src/androidMain/res/values/ic_launcher_background.xml
similarity index 100%
rename from app/src/main/res/values/ic_launcher_background.xml
rename to sample/composeApp/src/androidMain/res/values/ic_launcher_background.xml
diff --git a/app/src/main/res/values/strings.xml b/sample/composeApp/src/androidMain/res/values/strings.xml
similarity index 100%
rename from app/src/main/res/values/strings.xml
rename to sample/composeApp/src/androidMain/res/values/strings.xml
diff --git a/sample/composeApp/src/commonMain/kotlin/com/appswithlove/updraftsdk/App.kt b/sample/composeApp/src/commonMain/kotlin/com/appswithlove/updraftsdk/App.kt
new file mode 100644
index 0000000..d8c9035
--- /dev/null
+++ b/sample/composeApp/src/commonMain/kotlin/com/appswithlove/updraftsdk/App.kt
@@ -0,0 +1,33 @@
+package com.appswithlove.updraftsdk
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.appswithlove.updraft.Updraft
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleApp() {
+ MaterialTheme {
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Updraft SDK Sample") }) },
+ ) { innerPadding ->
+ Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
+ Button(onClick = { Updraft.checkForUpdate() }) {
+ Text("Check for update")
+ }
+ Button(onClick = { Updraft.showFeedback() }) {
+ Text("Give feedback")
+ }
+ }
+ }
+ }
+}
diff --git a/sample/composeApp/src/iosMain/kotlin/com/appswithlove/updraftsdk/MainViewController.kt b/sample/composeApp/src/iosMain/kotlin/com/appswithlove/updraftsdk/MainViewController.kt
new file mode 100644
index 0000000..74af63f
--- /dev/null
+++ b/sample/composeApp/src/iosMain/kotlin/com/appswithlove/updraftsdk/MainViewController.kt
@@ -0,0 +1,22 @@
+package com.appswithlove.updraftsdk
+
+import androidx.compose.ui.window.ComposeUIViewController
+import com.appswithlove.updraft.LogLevel
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftSettings
+import com.appswithlove.updraft.ui.ios.UpdraftIos
+import platform.UIKit.UIViewController
+
+fun MainViewController(): UIViewController = ComposeUIViewController { SampleApp() }
+
+fun startUpdraft() {
+ Updraft.start(
+ UpdraftSettings(
+ appKey = SampleKeys.APP_KEY_IOS,
+ sdkKey = SampleKeys.SDK_KEY,
+ logLevel = LogLevel.Debug,
+ showFeedbackAlert = true,
+ ),
+ )
+ UpdraftIos.autoWire()
+}
diff --git a/app/updraft_test.jks b/sample/composeApp/updraft_test.jks
similarity index 100%
rename from app/updraft_test.jks
rename to sample/composeApp/updraft_test.jks
diff --git a/sample/iosApp/iosApp.xcodeproj/project.pbxproj b/sample/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..0a9f610
--- /dev/null
+++ b/sample/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,311 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 307C2654CD5972A25FB7FD87 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668CECA83ACC03A83C85A623 /* iOSApp.swift */; };
+ 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 668CECA83ACC03A83C85A623 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
+ 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ C117AD187833E2513BD2F553 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */ = {
+ isa = PBXGroup;
+ children = (
+ CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */,
+ C117AD187833E2513BD2F553 /* Info.plist */,
+ 668CECA83ACC03A83C85A623 /* iOSApp.swift */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+ 38B69C3F79CD97858CF7B609 = {
+ isa = PBXGroup;
+ children = (
+ 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */,
+ 94C98B8B3A2BFC1966447269 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 94C98B8B3A2BFC1966447269 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 86038AE1032E284E79B32AD6 /* iosApp.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 78B53CC79860CBFC2F5543C6 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ 4B44A75E2FD10EFE3C97FCF5 /* Compile Kotlin Framework */,
+ 297B59D9CE634556D6007C40 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = iosApp;
+ packageProductDependencies = (
+ );
+ productName = iosApp;
+ productReference = 86038AE1032E284E79B32AD6 /* iosApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 242FB5348943441C388468CE /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ };
+ buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 38B69C3F79CD97858CF7B609;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 78B53CC79860CBFC2F5543C6 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 4B44A75E2FD10EFE3C97FCF5 /* Compile Kotlin Framework */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Compile Kotlin Framework";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/../..\"\n./gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 297B59D9CE634556D6007C40 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */,
+ 307C2654CD5972A25FB7FD87 /* iOSApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 11814011E7D6EC822BFBC66D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 6C824A6EB4BAD43AE2A4BB29 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 9221A1FDC06803E8A2FC6E61 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.appswithlove.updraftsdk.ios;
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ AF835BFF1220CA5AB5B867E4 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.appswithlove.updraftsdk.ios;
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 9221A1FDC06803E8A2FC6E61 /* Debug */,
+ AF835BFF1220CA5AB5B867E4 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 6C824A6EB4BAD43AE2A4BB29 /* Debug */,
+ 11814011E7D6EC822BFBC66D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 242FB5348943441C388468CE /* Project object */;
+}
diff --git a/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/sample/iosApp/iosApp/ContentView.swift b/sample/iosApp/iosApp/ContentView.swift
new file mode 100644
index 0000000..3a432c6
--- /dev/null
+++ b/sample/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+import SampleApp
+
+struct ContentView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
diff --git a/sample/iosApp/iosApp/Info.plist b/sample/iosApp/iosApp/Info.plist
new file mode 100644
index 0000000..06567ac
--- /dev/null
+++ b/sample/iosApp/iosApp/Info.plist
@@ -0,0 +1,50 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/sample/iosApp/iosApp/iOSApp.swift b/sample/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 0000000..afdafc7
--- /dev/null
+++ b/sample/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+import SampleApp
+
+@main
+struct iOSApp: App {
+ init() {
+ MainViewControllerKt.startUpdraft()
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/sample/iosApp/project.yml b/sample/iosApp/project.yml
new file mode 100644
index 0000000..c76d04b
--- /dev/null
+++ b/sample/iosApp/project.yml
@@ -0,0 +1,29 @@
+name: iosApp
+options:
+ bundleIdPrefix: com.appswithlove
+settings:
+ base:
+ ENABLE_USER_SCRIPT_SANDBOXING: NO
+targets:
+ iosApp:
+ type: application
+ platform: iOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: iosApp
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.appswithlove.updraftsdk.ios
+ INFOPLIST_FILE: iosApp/Info.plist
+ SWIFT_VERSION: 5.0
+ TARGETED_DEVICE_FAMILY: "1,2"
+ preBuildScripts:
+ - name: Compile Kotlin Framework
+ script: |
+ if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
+ echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
+ exit 0
+ fi
+ cd "$SRCROOT/../.."
+ ./gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode
+ basedOnDependencyAnalysis: false
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1cdbc79..eec447e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "updraft-sdk-android"
-include(":app", ":updraft-sdk")
+rootProject.name = "updraft-sdk"
+include(":sample:composeApp", ":updraft-sdk", ":updraft-core", ":updraft-ui-compose")
diff --git a/updraft-core/build.gradle.kts b/updraft-core/build.gradle.kts
new file mode 100644
index 0000000..04ecdc7
--- /dev/null
+++ b/updraft-core/build.gradle.kts
@@ -0,0 +1,73 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.maven.publish)
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+ }
+
+ val xcf = XCFramework("UpdraftCore")
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ iosX64(),
+ ).forEach { target ->
+ target.binaries.framework {
+ baseName = "UpdraftCore"
+ isStatic = true
+ xcf.add(this)
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.ktor.client.mock)
+ implementation(libs.turbine)
+ }
+ androidMain.dependencies {
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.androidx.startup)
+ implementation(libs.lifecycle.process)
+ implementation(libs.core.ktx)
+ }
+ iosMain.dependencies {
+ implementation(libs.ktor.client.darwin)
+ }
+ }
+}
+
+android {
+ namespace = "com.appswithlove.updraft.core"
+ compileSdk = 36
+ defaultConfig {
+ minSdk = 23
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+mavenPublishing {
+ publishToMavenCentral(automaticRelease = true)
+ signAllPublications()
+}
diff --git a/updraft-core/gradle.properties b/updraft-core/gradle.properties
new file mode 100644
index 0000000..3ed6cc9
--- /dev/null
+++ b/updraft-core/gradle.properties
@@ -0,0 +1,20 @@
+GROUP=com.appswithlove.updraft
+POM_ARTIFACT_ID=updraft-core
+VERSION_NAME=2.0.0
+
+POM_NAME=updraft-core
+POM_DESCRIPTION=Kotlin Multiplatform core logic for connecting with GetUpdraft platform
+POM_INCEPTION_YEAR=2020
+
+POM_URL=https://github.com/appswithlove/updraft-sdk
+POM_SCM_URL=https://github.com/appswithlove/updraft-sdk/
+POM_SCM_CONNECTION=scm:git:git://github.com/appswithlove/updraft-sdk.git
+POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/appswithlove/updraft-sdk.git
+
+POM_LICENSE_NAME=The Apache Software License, Version 2.0
+POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt
+POM_LICENSE_DIST=repo
+
+POM_DEVELOPER_ID=yannickpulver
+POM_DEVELOPER_NAME=Yannick Pulver
+POM_DEVELOPER_URL=https://github.com/yannickpulver/
diff --git a/updraft-core/src/androidMain/AndroidManifest.xml b/updraft-core/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..77236ed
--- /dev/null
+++ b/updraft-core/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/AppInfo.android.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/AppInfo.android.kt
new file mode 100644
index 0000000..89c70bd
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/AppInfo.android.kt
@@ -0,0 +1,28 @@
+package com.appswithlove.updraft.platform
+
+import android.annotation.SuppressLint
+import android.content.pm.PackageManager
+import android.os.Build
+import android.provider.Settings
+
+@SuppressLint("HardwareIds")
+actual fun currentAppInfo(): AppInfo {
+ val context = UpdraftContext.application
+ val packageInfo = try {
+ context.packageManager.getPackageInfo(context.packageName, 0)
+ } catch (e: PackageManager.NameNotFoundException) {
+ null
+ }
+ val versionCode = when {
+ packageInfo == null -> -1L
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> packageInfo.longVersionCode
+ else -> @Suppress("DEPRECATION") packageInfo.versionCode.toLong()
+ }
+ return AppInfo(
+ versionCode = versionCode,
+ versionName = packageInfo?.versionName.orEmpty(),
+ systemVersion = Build.VERSION.RELEASE.orEmpty(),
+ deviceName = Build.MODEL.orEmpty(),
+ deviceUuid = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
+ )
+}
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/CurrentActivityManager.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/CurrentActivityManager.kt
new file mode 100644
index 0000000..a7333ac
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/CurrentActivityManager.kt
@@ -0,0 +1,43 @@
+package com.appswithlove.updraft.platform
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+
+object CurrentActivityManager : Application.ActivityLifecycleCallbacks {
+
+ var current: Activity? = null
+ private set
+
+ private val activityStack = mutableListOf()
+ val stack: List get() = activityStack
+
+ private val listeners = mutableSetOf<(Activity?) -> Unit>()
+
+ fun addListener(listener: (Activity?) -> Unit) {
+ listeners.add(listener)
+ listener(current)
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ current = activity
+ listeners.forEach { it(activity) }
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ current = null
+ listeners.forEach { it(null) }
+ }
+
+ override fun onActivityCreated(activity: Activity, bundle: Bundle?) {
+ activityStack.add(activity)
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ activityStack.remove(activity)
+ }
+
+ override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
+}
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.android.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.android.kt
new file mode 100644
index 0000000..a8d61cd
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.android.kt
@@ -0,0 +1,13 @@
+package com.appswithlove.updraft.platform
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+private class SharedPrefsKeyValueStore(private val prefs: SharedPreferences) : KeyValueStore {
+ override fun getBoolean(key: String, default: Boolean): Boolean = prefs.getBoolean(key, default)
+ override fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
+}
+
+actual fun createKeyValueStore(name: String): KeyValueStore =
+ SharedPrefsKeyValueStore(UpdraftContext.application.getSharedPreferences(name, Context.MODE_PRIVATE))
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.android.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.android.kt
new file mode 100644
index 0000000..907072d
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.android.kt
@@ -0,0 +1,6 @@
+package com.appswithlove.updraft.platform
+
+actual fun currentNavigationStack(): String =
+ CurrentActivityManager.stack
+ .filterNot { it.javaClass.name.startsWith("com.appswithlove.updraft.") }
+ .joinToString(", ") { it.javaClass.simpleName }
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/Platform.android.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/Platform.android.kt
new file mode 100644
index 0000000..3fad3c5
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/Platform.android.kt
@@ -0,0 +1,104 @@
+package com.appswithlove.updraft.platform
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import androidx.core.net.toUri
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import java.io.ByteArrayOutputStream
+
+private class AndroidShakeDetector(private val onShake: () -> Unit) :
+ ShakeDetector, SensorEventListener, DefaultLifecycleObserver {
+
+ private val sensorManager =
+ UpdraftContext.application.getSystemService(android.content.Context.SENSOR_SERVICE) as SensorManager
+ private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+
+ private var shakeTimestamp = 0L
+ private var enabled = true
+
+ override fun start() {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override fun stop() {
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
+ sensorManager.unregisterListener(this)
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ this.enabled = enabled
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ accelerometer?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) }
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ sensorManager.unregisterListener(this)
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ val (x, y, z) = event.values
+ if (!isShakeGForce(x, y, z, SensorManager.GRAVITY_EARTH)) return
+
+ val now = System.currentTimeMillis()
+ if (shakeTimestamp + SHAKE_SLOP_TIME_MS > now) return
+ shakeTimestamp = now
+
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
+
+ companion object {
+ private const val SHAKE_SLOP_TIME_MS = 500
+ }
+}
+
+actual fun createShakeDetector(onShake: () -> Unit): ShakeDetector = AndroidShakeDetector(onShake)
+
+private class AndroidScreenshotGrabber : ScreenshotGrabber {
+ override fun capturePng(): ByteArray? {
+ val activity = CurrentActivityManager.current ?: return null
+ val view = activity.window.decorView.rootView
+ if (view.width == 0 || view.height == 0) return null
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ bitmap.recycle()
+ return stream.toByteArray()
+ }
+}
+
+actual fun createScreenshotGrabber(): ScreenshotGrabber = AndroidScreenshotGrabber()
+
+actual fun openUrl(url: String) {
+ val activity = CurrentActivityManager.current ?: return
+ activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+}
+
+private class AndroidForegroundObserver(
+ private val onForeground: () -> Unit,
+ private val onBackground: () -> Unit,
+) : AppForegroundObserver, DefaultLifecycleObserver {
+ override fun start() {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override fun onStart(owner: LifecycleOwner) = onForeground()
+ override fun onStop(owner: LifecycleOwner) = onBackground()
+}
+
+actual fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver =
+ AndroidForegroundObserver(onForeground, onBackground)
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftContext.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftContext.kt
new file mode 100644
index 0000000..288fab3
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftContext.kt
@@ -0,0 +1,8 @@
+package com.appswithlove.updraft.platform
+
+import android.app.Application
+
+object UpdraftContext {
+ lateinit var application: Application
+ internal set
+}
diff --git a/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftCoreInitializer.kt b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftCoreInitializer.kt
new file mode 100644
index 0000000..c4da15f
--- /dev/null
+++ b/updraft-core/src/androidMain/kotlin/com/appswithlove/updraft/platform/UpdraftCoreInitializer.kt
@@ -0,0 +1,15 @@
+package com.appswithlove.updraft.platform
+
+import android.app.Application
+import android.content.Context
+import androidx.startup.Initializer
+
+class UpdraftCoreInitializer : Initializer {
+ override fun create(context: Context): UpdraftContext {
+ UpdraftContext.application = context.applicationContext as Application
+ UpdraftContext.application.registerActivityLifecycleCallbacks(CurrentActivityManager)
+ return UpdraftContext
+ }
+
+ override fun dependencies(): List>> = emptyList()
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/FeedbackType.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/FeedbackType.kt
new file mode 100644
index 0000000..4e7e387
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/FeedbackType.kt
@@ -0,0 +1,7 @@
+package com.appswithlove.updraft
+
+enum class FeedbackType(val apiName: String) {
+ Design("design"),
+ Feedback("feedback"),
+ Bug("bug"),
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/Updraft.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/Updraft.kt
new file mode 100644
index 0000000..a831e6a
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/Updraft.kt
@@ -0,0 +1,189 @@
+package com.appswithlove.updraft
+
+import com.appswithlove.updraft.api.UpdraftApi
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.interactor.CheckFeedbackEnabledInteractor
+import com.appswithlove.updraft.interactor.CheckFeedbackResultModel
+import com.appswithlove.updraft.interactor.CheckUpdateInteractor
+import com.appswithlove.updraft.platform.KeyValueStore
+import com.appswithlove.updraft.platform.ShakeDetector
+import com.appswithlove.updraft.platform.createAppForegroundObserver
+import com.appswithlove.updraft.platform.createKeyValueStore
+import com.appswithlove.updraft.platform.createScreenshotGrabber
+import com.appswithlove.updraft.platform.createShakeDetector
+import com.appswithlove.updraft.platform.currentAppInfo
+import com.appswithlove.updraft.platform.currentNavigationStack
+import com.appswithlove.updraft.platform.openUrl
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+
+internal class UpdraftController(
+ private val settings: UpdraftSettings,
+ private val api: UpdraftApiContract,
+ store: KeyValueStore,
+ private val scope: CoroutineScope,
+ private val navigationStackSource: () -> String = { "" },
+) {
+ private val checkUpdateInteractor = CheckUpdateInteractor(api)
+ private val checkFeedbackInteractor = CheckFeedbackEnabledInteractor(api, store)
+
+ private val _events = MutableSharedFlow(extraBufferCapacity = 16)
+ val events: SharedFlow = _events
+
+ private var updateAlertShown = false
+ private var feedbackHintShown = false
+ private var pendingScreenshot: ByteArray? = null
+ private var pendingNavigationStack: String? = null
+
+ var feedbackUiPresenter: FeedbackUiPresenter? = null
+
+ fun onForeground() {
+ if (settings.showFeedbackAlert && settings.feedbackEnabled && !feedbackHintShown) {
+ feedbackHintShown = true
+ _events.tryEmit(UpdraftEvent.ShowFeedbackHint)
+ }
+ checkForUpdate()
+ checkFeedbackEnabled()
+ }
+
+ fun checkForUpdate() {
+ scope.launch {
+ try {
+ val result = checkUpdateInteractor.checkUpdate()
+ val url = result.url
+ if (result.showAlert && url != null && !updateAlertShown) {
+ updateAlertShown = true
+ _events.tryEmit(
+ UpdraftEvent.UpdateAvailable(url, result.version, result.yourVersion, result.createAt),
+ )
+ }
+ } catch (t: Throwable) {
+ _events.tryEmit(UpdraftEvent.Error(t))
+ }
+ }
+ }
+
+ private fun checkFeedbackEnabled() {
+ scope.launch {
+ try {
+ val result = checkFeedbackInteractor.run()
+ if (result.showAlert) {
+ when (result.alertType) {
+ CheckFeedbackResultModel.AlertType.FeedbackDisabled ->
+ _events.tryEmit(UpdraftEvent.FeedbackDisabled)
+ CheckFeedbackResultModel.AlertType.HowToGiveFeedback ->
+ if (settings.showFeedbackAlert) _events.tryEmit(UpdraftEvent.ShowFeedbackHint)
+ }
+ }
+ if (!result.isFeedbackEnabled) {
+ _events.tryEmit(UpdraftEvent.CloseFeedback)
+ }
+ } catch (t: Throwable) {
+ _events.tryEmit(UpdraftEvent.Error(t))
+ }
+ }
+ }
+
+ fun onFeedbackTriggered(screenshotPng: ByteArray?) {
+ pendingScreenshot = screenshotPng
+ pendingNavigationStack = navigationStackSource()
+ val presenter = feedbackUiPresenter
+ if (presenter != null) {
+ presenter.presentFeedback(screenshotPng)
+ } else {
+ _events.tryEmit(UpdraftEvent.FeedbackRequested)
+ }
+ }
+
+ fun takePendingScreenshot(): ByteArray? {
+ val screenshot = pendingScreenshot
+ pendingScreenshot = null
+ return screenshot
+ }
+
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow {
+ val navigationStack = pendingNavigationStack ?: navigationStackSource()
+ pendingNavigationStack = null
+ return api.sendFeedback(screenshotPng, type, description, email, navigationStack)
+ }
+}
+
+object Updraft {
+ private var controller: UpdraftController? = null
+ private var currentSettings: UpdraftSettings? = null
+ private var shakeDetector: ShakeDetector? = null
+
+ val settings: UpdraftSettings
+ get() = checkNotNull(currentSettings) { "Must call Updraft.start() first" }
+
+ val events: SharedFlow
+ get() = requireController().events
+
+ /**
+ * Optional hook to report the app's navigation stack with feedback uploads,
+ * captured when feedback is triggered (e.g. on shake). Return screen names
+ * from your navigation library, ordered root to top. When null, the SDK
+ * falls back to the platform default: the activity stack on Android, the
+ * view controller chain on iOS.
+ */
+ var navigationStackProvider: (() -> List)? = null
+
+ private fun resolveNavigationStack(): String {
+ if (currentSettings?.sendNavigationStack != true) return ""
+ return navigationStackProvider?.invoke()?.joinToString(", ") ?: currentNavigationStack()
+ }
+
+ fun start(settings: UpdraftSettings) {
+ if (controller != null) return
+ currentSettings = settings
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val api = UpdraftApi(settings, currentAppInfo())
+ val store = createKeyValueStore(CheckFeedbackEnabledInteractor.STORE_NAME)
+ val newController = UpdraftController(settings, api, store, scope, ::resolveNavigationStack)
+ controller = newController
+
+ if (settings.feedbackEnabled) {
+ val detector = createShakeDetector { showFeedback() }
+ shakeDetector = detector
+ detector.start()
+ }
+ createAppForegroundObserver(
+ onForeground = { newController.onForeground() },
+ onBackground = { },
+ ).start()
+ }
+
+ fun checkForUpdate() = requireController().checkForUpdate()
+
+ fun showFeedback() {
+ val screenshot = createScreenshotGrabber().capturePng()
+ requireController().onFeedbackTriggered(screenshot)
+ }
+
+ fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String): Flow =
+ requireController().sendFeedback(screenshotPng, type, description, email)
+
+ fun openUpdateUrl(url: String) = openUrl(url)
+
+ fun setFeedbackUiPresenter(presenter: FeedbackUiPresenter?) {
+ requireController().feedbackUiPresenter = presenter
+ }
+
+ fun onFeedbackUiClosed() {
+ shakeDetector?.setEnabled(true)
+ }
+
+ /**
+ * Returns the screenshot captured for the pending feedback request, once.
+ * Call from your feedback UI host when handling [UpdraftEvent.FeedbackRequested].
+ */
+ fun takePendingScreenshot(): ByteArray? = requireController().takePendingScreenshot()
+
+ private fun requireController(): UpdraftController =
+ checkNotNull(controller) { "Must call Updraft.start() first" }
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftEvent.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftEvent.kt
new file mode 100644
index 0000000..787581a
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftEvent.kt
@@ -0,0 +1,20 @@
+package com.appswithlove.updraft
+
+sealed interface UpdraftEvent {
+ data class UpdateAvailable(
+ val url: String,
+ val version: String?,
+ val yourVersion: String?,
+ val createAt: String?,
+ ) : UpdraftEvent
+
+ data object ShowFeedbackHint : UpdraftEvent
+ data object FeedbackDisabled : UpdraftEvent
+ data object FeedbackRequested : UpdraftEvent
+ data object CloseFeedback : UpdraftEvent
+ data class Error(val cause: Throwable) : UpdraftEvent
+}
+
+fun interface FeedbackUiPresenter {
+ fun presentFeedback(screenshotPng: ByteArray?)
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftSettings.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftSettings.kt
new file mode 100644
index 0000000..debd0cc
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/UpdraftSettings.kt
@@ -0,0 +1,21 @@
+package com.appswithlove.updraft
+
+enum class LogLevel { None, Error, Debug }
+
+class UpdraftSettings(
+ val appKey: String,
+ val sdkKey: String,
+ val baseUrl: String = BASE_URL_PROD,
+ val logLevel: LogLevel = LogLevel.Error,
+ val showFeedbackAlert: Boolean = true,
+ val feedbackEnabled: Boolean = true,
+ val storeRelease: Boolean = false,
+ val sendNavigationStack: Boolean = true,
+) {
+ fun shouldShowErrors(): Boolean =
+ logLevel == LogLevel.Error || logLevel == LogLevel.Debug
+
+ companion object {
+ const val BASE_URL_PROD = "https://app.getupdraft.com/api/"
+ }
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/ApiException.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/ApiException.kt
new file mode 100644
index 0000000..c9e99f6
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/ApiException.kt
@@ -0,0 +1,3 @@
+package com.appswithlove.updraft.api
+
+class ApiException(message: String) : Exception(message)
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt
new file mode 100644
index 0000000..3c2faba
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/UpdraftApi.kt
@@ -0,0 +1,141 @@
+package com.appswithlove.updraft.api
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.LogLevel
+import com.appswithlove.updraft.UpdraftSettings
+import com.appswithlove.updraft.api.request.CheckLastVersionRequest
+import com.appswithlove.updraft.api.request.FeedbackEnabledRequest
+import com.appswithlove.updraft.api.request.GetLastVersionRequest
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.FeedbackEnabledResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.AppInfo
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel as KtorLogLevel
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.onUpload
+import io.ktor.client.request.forms.MultiPartFormDataContent
+import io.ktor.client.request.forms.formData
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.Headers
+import io.ktor.http.HttpHeaders
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+
+interface UpdraftApiContract {
+ suspend fun checkLastVersion(): CheckLastVersionResponse
+ suspend fun getLastVersion(): GetLastVersionResponse
+ suspend fun isFeedbackEnabled(): Boolean
+ fun sendFeedback(
+ screenshotPng: ByteArray,
+ type: FeedbackType,
+ description: String,
+ email: String,
+ navigationStack: String,
+ ): Flow
+}
+
+class UpdraftApi(
+ private val settings: UpdraftSettings,
+ private val appInfo: AppInfo,
+ engine: HttpClientEngine? = null,
+) : UpdraftApiContract {
+ private val client: HttpClient = (engine?.let { HttpClient(it) } ?: HttpClient()).config {
+ expectSuccess = true
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(Logging) {
+ level = if (settings.logLevel == LogLevel.Debug) KtorLogLevel.BODY else KtorLogLevel.NONE
+ }
+ }
+
+ private fun url(path: String) = settings.baseUrl + path
+
+ override suspend fun checkLastVersion(): CheckLastVersionResponse {
+ check(appInfo.versionCode >= 0) { "Version code is invalid" }
+ val request = CheckLastVersionRequest(
+ sdkKey = settings.sdkKey,
+ appKey = settings.appKey,
+ version = appInfo.versionCode.toString(),
+ )
+ return client.post(url("check_last_version/")) {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ override suspend fun getLastVersion(): GetLastVersionResponse =
+ client.post(url("get_last_version/")) {
+ contentType(ContentType.Application.Json)
+ setBody(GetLastVersionRequest(sdkKey = settings.sdkKey, appKey = settings.appKey))
+ }.body()
+
+ override suspend fun isFeedbackEnabled(): Boolean {
+ val response: FeedbackEnabledResponse = client.post(url("feedback-mobile-enabled/")) {
+ contentType(ContentType.Application.Json)
+ setBody(FeedbackEnabledRequest(sdkKey = settings.sdkKey, appKey = settings.appKey))
+ }.body()
+ if (response.errorCodes.isNotEmpty()) {
+ throw ApiException(response.errorDescriptions.firstOrNull().orEmpty())
+ }
+ return response.isFeedbackEnabled
+ }
+
+ override fun sendFeedback(
+ screenshotPng: ByteArray,
+ type: FeedbackType,
+ description: String,
+ email: String,
+ navigationStack: String,
+ ): Flow = callbackFlow {
+ val form = formData {
+ append(
+ "image",
+ screenshotPng,
+ Headers.build {
+ append(HttpHeaders.ContentType, "image/png")
+ append(HttpHeaders.ContentDisposition, "filename=\"UPDRAFT_SCREENSHOT.png\"")
+ },
+ )
+ append("app_key", settings.appKey)
+ append("sdk_key", settings.sdkKey)
+ append("tag", type.apiName)
+ append("description", description)
+ append("email", email)
+ append("build_version", appInfo.versionName)
+ append("system_version", appInfo.systemVersion)
+ append("device_name", appInfo.deviceName)
+ append("device_uudid", appInfo.deviceUuid)
+ append("navigation_stack", navigationStack)
+ }
+ val uploadJob = launch {
+ try {
+ client.post(url("feedback-mobile/")) {
+ setBody(MultiPartFormDataContent(form))
+ onUpload { bytesSentTotal, contentLength ->
+ if (contentLength != null && contentLength > 0) {
+ trySend(bytesSentTotal.toDouble() / contentLength.toDouble())
+ }
+ }
+ }
+ close()
+ } catch (t: kotlin.coroutines.cancellation.CancellationException) {
+ throw t
+ } catch (t: Throwable) {
+ close(t)
+ }
+ }
+ awaitClose { uploadJob.cancel() }
+ }
+}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/CheckLastVersionRequest.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/CheckLastVersionRequest.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/CheckLastVersionRequest.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/CheckLastVersionRequest.kt
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/FeedbackEnabledRequest.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/FeedbackEnabledRequest.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/FeedbackEnabledRequest.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/FeedbackEnabledRequest.kt
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt
similarity index 92%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt
index f92bed4..e9a41f0 100644
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/FeedbackMobileRequest.kt
@@ -14,6 +14,6 @@ data class FeedbackMobileRequest(
@SerialName("build_version") val buildVersion: String? = null,
@SerialName("system_version") val systemVersion: String? = null,
@SerialName("device_name") val deviceName: String? = null,
- @SerialName("device_uuid") val deviceUuid: String? = null,
+ @SerialName("device_uudid") val deviceUuid: String? = null,
@SerialName("navigation_stack") val navigationStack: String? = null,
)
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/GetLastVersionRequest.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/GetLastVersionRequest.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/request/GetLastVersionRequest.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/request/GetLastVersionRequest.kt
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/CheckLastVersionResponse.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/CheckLastVersionResponse.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/CheckLastVersionResponse.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/CheckLastVersionResponse.kt
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/FeedbackEnabledResponse.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/FeedbackEnabledResponse.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/FeedbackEnabledResponse.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/FeedbackEnabledResponse.kt
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt
similarity index 91%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt
index 3e40dea..31afec7 100644
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/FeedbackMobileResponse.kt
@@ -12,6 +12,6 @@ data class FeedbackMobileResponse(
@SerialName("email") val email: String? = null,
@SerialName("build_version") val buildVersion: String? = null,
@SerialName("device_name") val deviceName: String? = null,
- @SerialName("device_uuid") val deviceUuid: String? = null,
+ @SerialName("device_uudid") val deviceUuid: String? = null,
@SerialName("navigation_stack") val navigationStack: String? = null,
)
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/GetLastVersionResponse.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/GetLastVersionResponse.kt
similarity index 100%
rename from updraft-sdk/src/main/java/com/appswithlove/updraft/api/response/GetLastVersionResponse.kt
rename to updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/api/response/GetLastVersionResponse.kt
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt
new file mode 100644
index 0000000..cfe2036
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt
@@ -0,0 +1,28 @@
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.platform.KeyValueStore
+
+class CheckFeedbackEnabledInteractor(
+ private val api: UpdraftApiContract,
+ private val store: KeyValueStore,
+) {
+ suspend fun run(): CheckFeedbackResultModel {
+ val isEnabled = api.isFeedbackEnabled()
+ val previouslyEnabled = store.getBoolean(IS_FEEDBACK_ENABLED_PROPERTY, false)
+
+ val showAlert = isEnabled != previouslyEnabled
+ val alertType = if (!isEnabled && previouslyEnabled) {
+ CheckFeedbackResultModel.AlertType.FeedbackDisabled
+ } else {
+ CheckFeedbackResultModel.AlertType.HowToGiveFeedback
+ }
+ store.putBoolean(IS_FEEDBACK_ENABLED_PROPERTY, isEnabled)
+ return CheckFeedbackResultModel(showAlert, alertType, isEnabled)
+ }
+
+ companion object {
+ const val STORE_NAME = "feedback_enabled_storage"
+ private const val IS_FEEDBACK_ENABLED_PROPERTY = "is_feedback_enabled_property"
+ }
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt
new file mode 100644
index 0000000..5812e05
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt
@@ -0,0 +1,9 @@
+package com.appswithlove.updraft.interactor
+
+class CheckFeedbackResultModel(
+ val showAlert: Boolean,
+ val alertType: AlertType,
+ val isFeedbackEnabled: Boolean,
+) {
+ enum class AlertType { FeedbackDisabled, HowToGiveFeedback }
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt
new file mode 100644
index 0000000..b9c6485
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt
@@ -0,0 +1,21 @@
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.api.UpdraftApiContract
+
+class CheckUpdateInteractor(private val api: UpdraftApiContract) {
+
+ suspend fun checkUpdate(): CheckUpdateResultModel {
+ val check = api.checkLastVersion()
+ if (!check.isNewVersion || !check.isAutoupdateEnabled) {
+ return CheckUpdateResultModel(showAlert = false)
+ }
+ val url = api.getLastVersion().updateUrl
+ return CheckUpdateResultModel(
+ showAlert = url != null,
+ url = url,
+ version = check.version,
+ yourVersion = check.yourVersion,
+ createAt = check.createAt,
+ )
+ }
+}
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt
new file mode 100644
index 0000000..f3e63ec
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt
@@ -0,0 +1,9 @@
+package com.appswithlove.updraft.interactor
+
+data class CheckUpdateResultModel(
+ val showAlert: Boolean,
+ val url: String? = null,
+ val version: String? = null,
+ val yourVersion: String? = null,
+ val createAt: String? = null,
+)
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/AppInfo.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/AppInfo.kt
new file mode 100644
index 0000000..9ad0a7a
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/AppInfo.kt
@@ -0,0 +1,11 @@
+package com.appswithlove.updraft.platform
+
+class AppInfo(
+ val versionCode: Long,
+ val versionName: String,
+ val systemVersion: String,
+ val deviceName: String,
+ val deviceUuid: String,
+)
+
+expect fun currentAppInfo(): AppInfo
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.kt
new file mode 100644
index 0000000..096fab2
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.kt
@@ -0,0 +1,8 @@
+package com.appswithlove.updraft.platform
+
+interface KeyValueStore {
+ fun getBoolean(key: String, default: Boolean): Boolean
+ fun putBoolean(key: String, value: Boolean)
+}
+
+expect fun createKeyValueStore(name: String): KeyValueStore
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.kt
new file mode 100644
index 0000000..ded0f35
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.kt
@@ -0,0 +1,7 @@
+package com.appswithlove.updraft.platform
+
+/**
+ * Snapshot of the host app's current navigation stack as comma-separated
+ * screen names, excluding Updraft's own screens. Empty when unavailable.
+ */
+expect fun currentNavigationStack(): String
diff --git a/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/Platform.kt b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/Platform.kt
new file mode 100644
index 0000000..f4d0c5d
--- /dev/null
+++ b/updraft-core/src/commonMain/kotlin/com/appswithlove/updraft/platform/Platform.kt
@@ -0,0 +1,34 @@
+package com.appswithlove.updraft.platform
+
+import kotlin.math.sqrt
+
+interface ShakeDetector {
+ fun start()
+ fun stop()
+ fun setEnabled(enabled: Boolean)
+}
+
+expect fun createShakeDetector(onShake: () -> Unit): ShakeDetector
+
+interface ScreenshotGrabber {
+ fun capturePng(): ByteArray?
+}
+
+expect fun createScreenshotGrabber(): ScreenshotGrabber
+
+expect fun openUrl(url: String)
+
+interface AppForegroundObserver {
+ fun start()
+}
+
+expect fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver
+
+const val SHAKE_THRESHOLD_GRAVITY = 2.7f
+
+fun isShakeGForce(x: Float, y: Float, z: Float, gravity: Float): Boolean {
+ val gX = x / gravity
+ val gY = y / gravity
+ val gZ = z / gravity
+ return sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()) > SHAKE_THRESHOLD_GRAVITY
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/FeedbackTypeTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/FeedbackTypeTest.kt
new file mode 100644
index 0000000..becc6c0
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/FeedbackTypeTest.kt
@@ -0,0 +1,13 @@
+package com.appswithlove.updraft
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FeedbackTypeTest {
+ @Test
+ fun apiNames_matchServerContract() {
+ assertEquals("design", FeedbackType.Design.apiName)
+ assertEquals("feedback", FeedbackType.Feedback.apiName)
+ assertEquals("bug", FeedbackType.Bug.apiName)
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftControllerTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftControllerTest.kt
new file mode 100644
index 0000000..20c54cb
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftControllerTest.kt
@@ -0,0 +1,79 @@
+package com.appswithlove.updraft
+
+import app.cash.turbine.test
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.KeyValueStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+private class FakeStore : KeyValueStore {
+ val map = mutableMapOf()
+ override fun getBoolean(key: String, default: Boolean) = map[key] ?: default
+ override fun putBoolean(key: String, value: Boolean) { map[key] = value }
+}
+
+private class FakeApi : UpdraftApiContract {
+ var check = CheckLastVersionResponse(isNewVersion = false, isAutoupdateEnabled = false)
+ var last = GetLastVersionResponse()
+ var feedbackEnabled = true
+ override suspend fun checkLastVersion() = check
+ override suspend fun getLastVersion() = last
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String, navigationStack: String): Flow = emptyFlow()
+}
+
+class UpdraftControllerTest {
+
+ private val settings = UpdraftSettings(appKey = "a", sdkKey = "s", showFeedbackAlert = true)
+
+ @Test
+ fun onForeground_updateAvailable_emitsEvent() = runTest {
+ val api = FakeApi().apply {
+ check = CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true, version = "7")
+ last = GetLastVersionResponse(updateUrl = "https://u")
+ feedbackEnabled = true
+ }
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val controller = UpdraftController(settings, api, store, backgroundScope)
+
+ controller.events.test {
+ controller.onForeground()
+ assertIs(awaitItem())
+ val event = awaitItem()
+ val update = assertIs(event)
+ assertEquals("https://u", update.url)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onForeground_feedbackBecameDisabled_emitsFeedbackDisabled() = runTest {
+ val api = FakeApi().apply { feedbackEnabled = false }
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val controller = UpdraftController(settings, api, store, backgroundScope)
+
+ controller.events.test {
+ controller.onForeground()
+ assertIs(awaitItem())
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun feedbackRequested_emittedOnShakePath() = runTest {
+ val controller = UpdraftController(settings, FakeApi(), FakeStore(), backgroundScope)
+ controller.events.test {
+ controller.onFeedbackTriggered(screenshotPng = byteArrayOf(1))
+ assertIs(awaitItem())
+ assertEquals(1, controller.takePendingScreenshot()!!.size)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftSettingsTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftSettingsTest.kt
new file mode 100644
index 0000000..7891a0d
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/UpdraftSettingsTest.kt
@@ -0,0 +1,27 @@
+package com.appswithlove.updraft
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class UpdraftSettingsTest {
+ private fun settings(logLevel: LogLevel) =
+ UpdraftSettings(appKey = "a", sdkKey = "s", logLevel = logLevel)
+
+ @Test
+ fun shouldShowErrors_trueForErrorAndDebug() {
+ assertTrue(settings(LogLevel.Error).shouldShowErrors())
+ assertTrue(settings(LogLevel.Debug).shouldShowErrors())
+ assertFalse(settings(LogLevel.None).shouldShowErrors())
+ }
+
+ @Test
+ fun defaults_matchLegacySdk() {
+ val s = UpdraftSettings(appKey = "a", sdkKey = "s")
+ assertEquals("https://app.getupdraft.com/api/", s.baseUrl)
+ assertEquals(LogLevel.Error, s.logLevel)
+ assertTrue(s.feedbackEnabled)
+ assertFalse(s.storeRelease)
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/api/UpdraftApiTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/api/UpdraftApiTest.kt
new file mode 100644
index 0000000..2d505bd
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/api/UpdraftApiTest.kt
@@ -0,0 +1,95 @@
+package com.appswithlove.updraft.api
+
+import app.cash.turbine.test
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.UpdraftSettings
+import com.appswithlove.updraft.platform.AppInfo
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.toByteArray
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class UpdraftApiTest {
+
+ private val settings = UpdraftSettings(appKey = "APP", sdkKey = "SDK", baseUrl = "https://example.com/api/")
+ private val appInfo = AppInfo(42L, "1.2.3", "16", "Pixel", "uuid-1")
+
+ @Test
+ fun checkLastVersion_sendsKeysAndVersionCode() = runTest {
+ var requestBody = ""
+ val engine = MockEngine { request ->
+ requestBody = request.body.toByteArray().decodeToString()
+ assertEquals("/api/check_last_version/", request.url.encodedPath)
+ respond(
+ """{"is_new_version":true,"is_autoupdate_enabled":true,"version":"9"}""",
+ HttpStatusCode.OK,
+ headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ }
+ val response = UpdraftApi(settings, appInfo, engine).checkLastVersion()
+ val sent = Json.parseToJsonElement(requestBody).jsonObject
+ assertEquals("APP", sent["app_key"]!!.jsonPrimitive.content)
+ assertEquals("SDK", sent["sdk_key"]!!.jsonPrimitive.content)
+ assertEquals("42", sent["version"]!!.jsonPrimitive.content)
+ assertTrue(response.isNewVersion)
+ }
+
+ @Test
+ fun isFeedbackEnabled_throwsApiExceptionOnErrorCodes() = runTest {
+ val engine = MockEngine {
+ respond(
+ """{"is_feedback_enabled":false,"error_code":["x"],"error_description":["broken"]}""",
+ HttpStatusCode.OK,
+ headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ }
+ val ex = assertFailsWith { UpdraftApi(settings, appInfo, engine).isFeedbackEnabled() }
+ assertEquals("broken", ex.message)
+ }
+
+ @Test
+ fun sendFeedback_uploadsMultipartAndCompletes() = runTest {
+ var contentType = ""
+ var bodyText = ""
+ val engine = MockEngine { request ->
+ contentType = request.body.contentType.toString()
+ bodyText = request.body.toByteArray().decodeToString()
+ assertEquals("/api/feedback-mobile/", request.url.encodedPath)
+ respond("""{}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
+ }
+ UpdraftApi(settings, appInfo, engine)
+ .sendFeedback(byteArrayOf(1, 2, 3), FeedbackType.Bug, "desc", "a@b.c", "HomeScreen, DetailScreen")
+ .test {
+ assertEquals(1.0, awaitItem())
+ awaitComplete()
+ }
+ assertTrue(contentType.startsWith("multipart/form-data"))
+ assertTrue(bodyText.contains("name=app_key") || bodyText.contains("name=\"app_key\""))
+ assertTrue(bodyText.contains("bug"))
+ assertTrue(bodyText.contains("Pixel"))
+ assertTrue(bodyText.contains("name=navigation_stack") || bodyText.contains("name=\"navigation_stack\""))
+ assertTrue(bodyText.contains("HomeScreen, DetailScreen"))
+ }
+
+ @Test
+ fun sendFeedback_httpError_flowFailsWithError() = runTest {
+ val engine = MockEngine {
+ respond("Bad Request", HttpStatusCode.BadRequest)
+ }
+ UpdraftApi(settings, appInfo, engine)
+ .sendFeedback(byteArrayOf(1, 2, 3), FeedbackType.Bug, "desc", "a@b.c", "HomeScreen, DetailScreen")
+ .test {
+ awaitError()
+ }
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractorTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractorTest.kt
new file mode 100644
index 0000000..762614d
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractorTest.kt
@@ -0,0 +1,55 @@
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import com.appswithlove.updraft.platform.KeyValueStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private class FakeStore : KeyValueStore {
+ val map = mutableMapOf()
+ override fun getBoolean(key: String, default: Boolean) = map[key] ?: default
+ override fun putBoolean(key: String, value: Boolean) { map[key] = value }
+}
+
+private class FakeApi(var feedbackEnabled: Boolean) : UpdraftApiContract {
+ override suspend fun checkLastVersion() = CheckLastVersionResponse(isNewVersion = false, isAutoupdateEnabled = false)
+ override suspend fun getLastVersion() = GetLastVersionResponse()
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String, navigationStack: String): Flow = emptyFlow()
+}
+
+class CheckFeedbackEnabledInteractorTest {
+
+ @Test
+ fun becameEnabled_showsHowToAlert_andPersists() = runTest {
+ val store = FakeStore()
+ val result = CheckFeedbackEnabledInteractor(FakeApi(true), store).run()
+ assertTrue(result.showAlert)
+ assertEquals(CheckFeedbackResultModel.AlertType.HowToGiveFeedback, result.alertType)
+ assertTrue(store.map["is_feedback_enabled_property"]!!)
+ }
+
+ @Test
+ fun becameDisabled_showsDisabledAlert() = runTest {
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ val result = CheckFeedbackEnabledInteractor(FakeApi(false), store).run()
+ assertTrue(result.showAlert)
+ assertEquals(CheckFeedbackResultModel.AlertType.FeedbackDisabled, result.alertType)
+ assertFalse(result.isFeedbackEnabled)
+ }
+
+ @Test
+ fun unchanged_noAlert() = runTest {
+ val store = FakeStore().apply { map["is_feedback_enabled_property"] = true }
+ assertFalse(CheckFeedbackEnabledInteractor(FakeApi(true), store).run().showAlert)
+ assertFalse(CheckFeedbackEnabledInteractor(FakeApi(false), FakeStore()).run().showAlert)
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractorTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractorTest.kt
new file mode 100644
index 0000000..1577ef2
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/interactor/CheckUpdateInteractorTest.kt
@@ -0,0 +1,55 @@
+package com.appswithlove.updraft.interactor
+
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.api.UpdraftApiContract
+import com.appswithlove.updraft.api.response.CheckLastVersionResponse
+import com.appswithlove.updraft.api.response.GetLastVersionResponse
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private class FakeUpdateApi(
+ var checkResponse: CheckLastVersionResponse,
+ var lastVersionResponse: GetLastVersionResponse = GetLastVersionResponse(updateUrl = null),
+ var feedbackEnabled: Boolean = true,
+) : UpdraftApiContract {
+ override suspend fun checkLastVersion() = checkResponse
+ override suspend fun getLastVersion() = lastVersionResponse
+ override suspend fun isFeedbackEnabled() = feedbackEnabled
+ override fun sendFeedback(screenshotPng: ByteArray, type: FeedbackType, description: String, email: String, navigationStack: String): Flow = emptyFlow()
+}
+
+class CheckUpdateInteractorTest {
+
+ @Test
+ fun newVersionWithUrl_showsAlert() = runTest {
+ val api = FakeUpdateApi(
+ CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true, version = "5", yourVersion = "4", createAt = "2026-01-01T00:00:00Z"),
+ GetLastVersionResponse(updateUrl = "https://dl.example/app"),
+ )
+ val result = CheckUpdateInteractor(api).checkUpdate()
+ assertTrue(result.showAlert)
+ assertEquals("https://dl.example/app", result.url)
+ assertEquals("5", result.version)
+ assertEquals("4", result.yourVersion)
+ }
+
+ @Test
+ fun newVersionWithoutUrl_noAlert() = runTest {
+ val api = FakeUpdateApi(
+ CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = true),
+ GetLastVersionResponse(updateUrl = null),
+ )
+ assertFalse(CheckUpdateInteractor(api).checkUpdate().showAlert)
+ }
+
+ @Test
+ fun autoupdateDisabled_noAlert() = runTest {
+ val api = FakeUpdateApi(CheckLastVersionResponse(isNewVersion = true, isAutoupdateEnabled = false))
+ assertFalse(CheckUpdateInteractor(api).checkUpdate().showAlert)
+ }
+}
diff --git a/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/platform/ShakeMathTest.kt b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/platform/ShakeMathTest.kt
new file mode 100644
index 0000000..290c244
--- /dev/null
+++ b/updraft-core/src/commonTest/kotlin/com/appswithlove/updraft/platform/ShakeMathTest.kt
@@ -0,0 +1,19 @@
+package com.appswithlove.updraft.platform
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ShakeMathTest {
+ private val g = 9.80665f
+
+ @Test
+ fun restingPhone_noShake() {
+ assertFalse(isShakeGForce(0f, 0f, g, g))
+ }
+
+ @Test
+ fun hardShake_detected() {
+ assertTrue(isShakeGForce(3f * g, 0f, 0f, g))
+ }
+}
diff --git a/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/AppInfo.ios.kt b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/AppInfo.ios.kt
new file mode 100644
index 0000000..b683d92
--- /dev/null
+++ b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/AppInfo.ios.kt
@@ -0,0 +1,18 @@
+package com.appswithlove.updraft.platform
+
+import platform.Foundation.NSBundle
+import platform.UIKit.UIDevice
+
+actual fun currentAppInfo(): AppInfo {
+ val info = NSBundle.mainBundle.infoDictionary
+ val versionCode = (info?.get("CFBundleVersion") as? String)?.toLongOrNull() ?: -1L
+ val versionName = info?.get("CFBundleShortVersionString") as? String ?: ""
+ val device = UIDevice.currentDevice
+ return AppInfo(
+ versionCode = versionCode,
+ versionName = versionName,
+ systemVersion = device.systemVersion,
+ deviceName = device.model,
+ deviceUuid = device.identifierForVendor?.UUIDString ?: "",
+ )
+}
diff --git a/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.ios.kt b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.ios.kt
new file mode 100644
index 0000000..10e3416
--- /dev/null
+++ b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/KeyValueStore.ios.kt
@@ -0,0 +1,15 @@
+package com.appswithlove.updraft.platform
+
+import platform.Foundation.NSUserDefaults
+
+private class UserDefaultsKeyValueStore(private val defaults: NSUserDefaults) : KeyValueStore {
+ override fun getBoolean(key: String, default: Boolean): Boolean =
+ if (defaults.objectForKey(key) != null) defaults.boolForKey(key) else default
+
+ override fun putBoolean(key: String, value: Boolean) {
+ defaults.setBool(value, key)
+ }
+}
+
+actual fun createKeyValueStore(name: String): KeyValueStore =
+ UserDefaultsKeyValueStore(NSUserDefaults(suiteName = name))
diff --git a/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/MotionShakeHook.kt b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/MotionShakeHook.kt
new file mode 100644
index 0000000..ba190a7
--- /dev/null
+++ b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/MotionShakeHook.kt
@@ -0,0 +1,63 @@
+package com.appswithlove.updraft.platform
+
+import kotlinx.cinterop.COpaquePointer
+import kotlinx.cinterop.CFunction
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCClass
+import kotlinx.cinterop.invoke
+import kotlinx.cinterop.reinterpret
+import kotlinx.cinterop.staticCFunction
+import platform.Foundation.NSLog
+import platform.darwin.NSInteger
+import platform.objc.class_getInstanceMethod
+import platform.objc.method_getImplementation
+import platform.objc.method_setImplementation
+import platform.objc.objc_getClass
+import platform.objc.sel_registerName
+
+private const val UI_EVENT_SUBTYPE_MOTION_SHAKE: NSInteger = 1
+
+private var installed = false
+private var onShakeCallback: (() -> Unit)? = null
+
+@OptIn(ExperimentalForeignApi::class)
+private var originalImp: COpaquePointer? = null
+
+@OptIn(ExperimentalForeignApi::class)
+private fun motionEndedImp(self: COpaquePointer?, cmd: COpaquePointer?, motion: NSInteger, event: COpaquePointer?) {
+ if (motion == UI_EVENT_SUBTYPE_MOTION_SHAKE) {
+ NSLog("Updraft: shake motion received")
+ onShakeCallback?.invoke()
+ }
+ originalImp
+ ?.reinterpret Unit>>()
+ ?.invoke(self, cmd, motion, event)
+}
+
+/**
+ * Replaces UIWindow.motionEnded:withEvent: so shake gestures reach the SDK
+ * without any host-app integration. This is the only mechanism that works on
+ * the simulator (Device > Shake sends a responder-chain motion event, not
+ * accelerometer data) and it also covers physical devices. The replacement is
+ * a raw C function (IMP) — Kotlin lambda-to-block bridging must not be used
+ * here because it would marshal the NSInteger motion argument as an object.
+ */
+@OptIn(ExperimentalForeignApi::class)
+internal fun installMotionShakeHook(onShake: () -> Unit) {
+ onShakeCallback = onShake
+ if (installed) return
+ installed = true
+
+ val windowClass = objc_getClass("UIWindow") ?: run {
+ NSLog("Updraft: UIWindow class not found, shake hook not installed")
+ return
+ }
+ val selector = sel_registerName("motionEnded:withEvent:")
+ val method = class_getInstanceMethod(windowClass as? ObjCClass, selector) ?: run {
+ NSLog("Updraft: motionEnded:withEvent: not found, shake hook not installed")
+ return
+ }
+ originalImp = method_getImplementation(method)
+ method_setImplementation(method, staticCFunction(::motionEndedImp).reinterpret())
+ NSLog("Updraft: shake hook installed on UIWindow")
+}
diff --git a/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.ios.kt b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.ios.kt
new file mode 100644
index 0000000..2870af5
--- /dev/null
+++ b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/NavigationStack.ios.kt
@@ -0,0 +1,34 @@
+package com.appswithlove.updraft.platform
+
+import platform.UIKit.UIApplication
+import platform.UIKit.UINavigationController
+import platform.UIKit.UITabBarController
+import platform.UIKit.UIViewController
+import platform.UIKit.UIWindow
+
+actual fun currentNavigationStack(): String {
+ val root = UIApplication.sharedApplication.windows
+ .filterIsInstance()
+ .firstOrNull { it.isKeyWindow() }
+ ?.rootViewController ?: return ""
+ val names = mutableListOf()
+ var pointed: UIViewController? = root
+ while (pointed != null) {
+ names += shownControllerNames(pointed)
+ pointed = pointed.presentedViewController
+ }
+ return names
+ .filterNot { it.startsWith("Updraft") || it.startsWith("Compose") }
+ .joinToString(", ")
+}
+
+private fun shownControllerNames(viewController: UIViewController): List = when (viewController) {
+ is UINavigationController ->
+ viewController.viewControllers.filterIsInstance().map { name(it) }
+ is UITabBarController ->
+ listOfNotNull(viewController.selectedViewController?.let { name(it) })
+ else -> listOf(name(viewController))
+}
+
+private fun name(viewController: UIViewController): String =
+ viewController::class.simpleName ?: "UIViewController"
diff --git a/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/Platform.ios.kt b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/Platform.ios.kt
new file mode 100644
index 0000000..8aa0bc0
--- /dev/null
+++ b/updraft-core/src/iosMain/kotlin/com/appswithlove/updraft/platform/Platform.ios.kt
@@ -0,0 +1,130 @@
+package com.appswithlove.updraft.platform
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.useContents
+import kotlinx.cinterop.usePinned
+import platform.CoreMotion.CMMotionManager
+import platform.Foundation.NSDate
+import platform.Foundation.NSNotificationCenter
+import platform.Foundation.NSOperationQueue
+import platform.Foundation.NSURL
+import platform.Foundation.timeIntervalSince1970
+import platform.UIKit.UIApplication
+import platform.UIKit.UIApplicationDidBecomeActiveNotification
+import platform.UIKit.UIApplicationDidEnterBackgroundNotification
+import platform.UIKit.UIApplicationUserDidTakeScreenshotNotification
+import platform.UIKit.UIGraphicsImageRenderer
+import platform.UIKit.UIImagePNGRepresentation
+import platform.UIKit.UIWindow
+import platform.posix.memcpy
+
+private class IosShakeDetector(private val onShake: () -> Unit) : ShakeDetector {
+ private val motionManager = CMMotionManager()
+ private var shakeTimestamp = 0.0
+ private var enabled = true
+ private var screenshotObserver: Any? = null
+
+ @OptIn(ExperimentalForeignApi::class)
+ override fun start() {
+ // Shake reaches iOS apps as a responder-chain motion event, not as
+ // accelerometer data — hook UIWindow to catch it (simulator + device).
+ installMotionShakeHook {
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+ // Legacy Updraft iOS trigger: user takes a screenshot. Only fires on
+ // physical hardware; the simulator's screenshot commands are host-side.
+ if (screenshotObserver == null) {
+ screenshotObserver = NSNotificationCenter.defaultCenter.addObserverForName(
+ UIApplicationUserDidTakeScreenshotNotification,
+ `object` = null,
+ queue = NSOperationQueue.mainQueue,
+ ) {
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+ }
+ if (!motionManager.accelerometerAvailable) return
+ motionManager.accelerometerUpdateInterval = 1.0 / 60.0
+ motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.mainQueue) { data, _ ->
+ val acceleration = data?.acceleration ?: return@startAccelerometerUpdatesToQueue
+ acceleration.useContents {
+ if (!isShakeGForce(x.toFloat(), y.toFloat(), z.toFloat(), 1.0f)) return@useContents
+ val now = NSDate().timeIntervalSince1970
+ if (shakeTimestamp + SHAKE_SLOP_SECONDS > now) return@useContents
+ shakeTimestamp = now
+ if (enabled) {
+ enabled = false
+ onShake()
+ }
+ }
+ }
+ }
+
+ override fun stop() {
+ motionManager.stopAccelerometerUpdates()
+ screenshotObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
+ screenshotObserver = null
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ this.enabled = enabled
+ }
+
+ companion object {
+ private const val SHAKE_SLOP_SECONDS = 0.5
+ }
+}
+
+actual fun createShakeDetector(onShake: () -> Unit): ShakeDetector = IosShakeDetector(onShake)
+
+@OptIn(ExperimentalForeignApi::class)
+private class IosScreenshotGrabber : ScreenshotGrabber {
+ override fun capturePng(): ByteArray? {
+ val window = keyWindow() ?: return null
+ val bounds = window.bounds
+ val renderer = UIGraphicsImageRenderer(bounds = bounds)
+ val image = renderer.imageWithActions { _ ->
+ window.drawViewHierarchyInRect(bounds, afterScreenUpdates = false)
+ }
+ val nsData = UIImagePNGRepresentation(image) ?: return null
+ val length = nsData.length.toInt()
+ if (length == 0) return null
+ val bytes = ByteArray(length)
+ bytes.usePinned { pinned ->
+ memcpy(pinned.addressOf(0), nsData.bytes, nsData.length)
+ }
+ return bytes
+ }
+
+ private fun keyWindow(): UIWindow? =
+ UIApplication.sharedApplication.windows
+ .filterIsInstance()
+ .firstOrNull { it.isKeyWindow() }
+}
+
+actual fun createScreenshotGrabber(): ScreenshotGrabber = IosScreenshotGrabber()
+
+actual fun openUrl(url: String) {
+ val nsUrl = NSURL.URLWithString(url) ?: return
+ UIApplication.sharedApplication.openURL(nsUrl, options = emptyMap(), completionHandler = null)
+}
+
+private class IosForegroundObserver(
+ private val onForeground: () -> Unit,
+ private val onBackground: () -> Unit,
+) : AppForegroundObserver {
+ override fun start() {
+ val center = NSNotificationCenter.defaultCenter
+ center.addObserverForName(UIApplicationDidBecomeActiveNotification, `object` = null, queue = NSOperationQueue.mainQueue) { onForeground() }
+ center.addObserverForName(UIApplicationDidEnterBackgroundNotification, `object` = null, queue = NSOperationQueue.mainQueue) { onBackground() }
+ }
+}
+
+actual fun createAppForegroundObserver(onForeground: () -> Unit, onBackground: () -> Unit): AppForegroundObserver =
+ IosForegroundObserver(onForeground, onBackground)
diff --git a/updraft-core/src/iosTest/kotlin/com/appswithlove/updraft/platform/IosSmokeTest.kt b/updraft-core/src/iosTest/kotlin/com/appswithlove/updraft/platform/IosSmokeTest.kt
new file mode 100644
index 0000000..e503ba7
--- /dev/null
+++ b/updraft-core/src/iosTest/kotlin/com/appswithlove/updraft/platform/IosSmokeTest.kt
@@ -0,0 +1,20 @@
+package com.appswithlove.updraft.platform
+
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class IosSmokeTest {
+ @Test
+ fun keyValueStore_roundTrips() {
+ val store = createKeyValueStore("updraft_test_suite")
+ store.putBoolean("k", true)
+ assertTrue(store.getBoolean("k", false))
+ }
+
+ @Test
+ fun appInfo_populates() {
+ val info = currentAppInfo()
+ assertNotNull(info.systemVersion)
+ }
+}
diff --git a/updraft-sdk/build.gradle.kts b/updraft-sdk/build.gradle.kts
index c844375..ec29c8c 100644
--- a/updraft-sdk/build.gradle.kts
+++ b/updraft-sdk/build.gradle.kts
@@ -5,8 +5,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.loco)
alias(libs.plugins.maven.publish)
id("base")
}
@@ -36,7 +36,6 @@ android {
buildFeatures {
buildConfig = true
- viewBinding = true
}
compileOptions {
@@ -54,42 +53,16 @@ android {
}
dependencies {
- implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
-
- implementation(libs.appcompat)
- implementation(libs.material)
-
- implementation(libs.retrofit)
- implementation(libs.adapter.rxjava2)
- implementation(libs.logging.interceptor)
-
- implementation(libs.ink)
-
- implementation(libs.lifecycle.extensions)
- implementation(libs.lifecycle.runtime)
- annotationProcessor(libs.lifecycle.compiler)
- implementation(libs.androidx.startup)
-
+ api(project(":updraft-core"))
+ api(project(":updraft-ui-compose"))
implementation(libs.core.ktx)
- implementation(libs.lifecycle.viewmodel.ktx)
- implementation(libs.kotlin.stdlib.jdk7)
- implementation(libs.kotlinx.serialization.json)
- implementation(libs.converter.kotlinx.serialization)
+ implementation(libs.androidx.startup)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.ui)
}
mavenPublishing {
publishToMavenCentral(automaticRelease = true)
signAllPublications()
}
-
-Loco {
- config {
- apiKey = "sOLd6p87EQ9zFoOeI8ytGBvGhKNIFo4f"
- lang = listOf("en", "de")
- defLang = "en"
- resDir = "$projectDir/src/main/res"
- fallbackLang = "en"
- orderByAssetId = true
- hideComments = true
- }
-}
diff --git a/updraft-sdk/gradle.properties b/updraft-sdk/gradle.properties
index d0ec89a..8af733a 100644
--- a/updraft-sdk/gradle.properties
+++ b/updraft-sdk/gradle.properties
@@ -12,16 +12,16 @@ org.gradle.jvmargs=-Xmx4096m
GROUP=com.appswithlove.updraft
POM_ARTIFACT_ID=updraft-sdk
-VERSION_NAME=1.1.2
+VERSION_NAME=2.0.0
-POM_NAME=updraft-sdk-android
-POM_DESCRIPTION=Plugin for connecting with GetUpdraft platform
+POM_NAME=updraft-sdk
+POM_DESCRIPTION=Updraft SDK for Android: auto-update and user feedback, wired up automatically
POM_INCEPTION_YEAR=2020
-POM_URL=https://github.com/appswithlove/updraft-sdk-android
-POM_SCM_URL=https://github.com/appswithlove/updraft-sdk-android/
-POM_SCM_CONNECTION=scm:git:git://github.com/appswithlove/updraft-sdk-android.git
-POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/appswithlove/updraft-sdk-android.git
+POM_URL=https://github.com/appswithlove/updraft-sdk
+POM_SCM_URL=https://github.com/appswithlove/updraft-sdk/
+POM_SCM_CONNECTION=scm:git:git://github.com/appswithlove/updraft-sdk.git
+POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/appswithlove/updraft-sdk.git
POM_LICENSE_NAME=The Apache Software License, Version 2.0
POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt
diff --git a/updraft-sdk/src/main/AndroidManifest.xml b/updraft-sdk/src/main/AndroidManifest.xml
index d401c87..b878868 100644
--- a/updraft-sdk/src/main/AndroidManifest.xml
+++ b/updraft-sdk/src/main/AndroidManifest.xml
@@ -1,23 +1,22 @@
-
-
-
-
-
+ android:name="com.appswithlove.updraft.android.UpdraftFeedbackActivity"
+ android:exported="false"
+ android:theme="@android:style/Theme.Material.NoActionBar" />
+
-
-
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/Settings.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/Settings.kt
deleted file mode 100644
index b394e93..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/Settings.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.appswithlove.updraft
-
-/**
- * Created by satori on 3/27/18.
- */
-class Settings {
- var sdkKey: String? = null
- var appKey: String? = null
- var isStoreRelease = false
- var baseUrl = BASE_URL_PROD
- var logLevel = LOG_LEVEL_ERROR
- var showFeedbackAlert = false
- var feedbackEnabled = true // force disabling feedback if needed
- fun shouldShowErrors(): Boolean {
- return logLevel == LOG_LEVEL_DEBUG || logLevel == LOG_LEVEL_ERROR
- }
-
- companion object {
- const val BASE_URL_STAGING = "https://u2.mqd.me/api/"
- const val BASE_URL_PROD = "https://app.getupdraft.com/api/"
- const val LOG_LEVEL_NONE = 0
- const val LOG_LEVEL_ERROR = 1
- const val LOG_LEVEL_DEBUG = 2
- }
-}
\ No newline at end of file
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/Updraft.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/Updraft.kt
deleted file mode 100644
index 3d90f31..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/Updraft.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.appswithlove.updraft
-
-import android.app.Application
-import com.appswithlove.updraft.api.ApiWrapper
-import com.appswithlove.updraft.interactor.CheckFeedbackEnabledInteractor
-import com.appswithlove.updraft.interactor.CheckUpdateInteractor
-import com.appswithlove.updraft.manager.AppUpdateManager
-import com.appswithlove.updraft.manager.CheckFeedbackEnabledManager
-import com.appswithlove.updraft.manager.CurrentActivityManger
-import com.appswithlove.updraft.manager.ShakeDetectorManager
-import com.appswithlove.updraft.presentation.DefaultScreenshotProvider
-import com.appswithlove.updraft.presentation.ScreenshotProvider
-import com.appswithlove.updraft.presentation.UpdraftSdkUi
-
-/**
- * Created by satori on 3/27/18.
- */
-class Updraft private constructor(
- application: Application,
- private val settings: Settings,
- screenshotProvider: ScreenshotProvider,
-) {
- private val mAppUpdateManager: AppUpdateManager
- val shakeDetectorManager: ShakeDetectorManager
- private val mCheckFeedbackEnabledManager: CheckFeedbackEnabledManager
- val apiWrapper: ApiWrapper = ApiWrapper(application, settings)
-
- fun start() {
- mAppUpdateManager.start()
- if (settings.feedbackEnabled) {
- shakeDetectorManager.start()
- }
- mCheckFeedbackEnabledManager.start()
- }
-
- init {
- val checkUpdateInteractor = CheckUpdateInteractor(apiWrapper)
- val updraftSdkUi = UpdraftSdkUi(CurrentActivityManger.INSTANCE, settings, screenshotProvider)
- mAppUpdateManager = AppUpdateManager(checkUpdateInteractor, updraftSdkUi)
- val checkFeedbackEnabledInteractor = CheckFeedbackEnabledInteractor(
- apiWrapper, application
- )
- mCheckFeedbackEnabledManager =
- CheckFeedbackEnabledManager(updraftSdkUi, checkFeedbackEnabledInteractor)
- shakeDetectorManager =
- ShakeDetectorManager(application, settings, mCheckFeedbackEnabledManager)
- }
-
- companion object {
- private const val NOT_INITIALIZED_MESSAGE =
- "Must initialize Updraft before using getInstance()"
- private var instance: Updraft? = null
- fun initialize(
- application: Application,
- settings: Settings,
- screenshotProvider: ScreenshotProvider = DefaultScreenshotProvider(),
- ) {
- createUpdraft(application, settings, screenshotProvider)
- }
-
- @Synchronized
- private fun createUpdraft(
- application: Application,
- settings: Settings,
- screenshotProvider: ScreenshotProvider,
- ) {
- if (instance == null) {
- instance = Updraft(application, settings, screenshotProvider)
- }
- }
-
- private fun checkInitialized() {
- checkNotNull(instance) { NOT_INITIALIZED_MESSAGE }
- }
-
- @JvmStatic
- fun getInstance(): Updraft? {
- checkInitialized()
- return instance
- }
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftAndroid.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftAndroid.kt
new file mode 100644
index 0000000..55bdb45
--- /dev/null
+++ b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftAndroid.kt
@@ -0,0 +1,51 @@
+package com.appswithlove.updraft.android
+
+import android.content.Context
+import androidx.startup.Initializer
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.platform.UpdraftContext
+import com.appswithlove.updraft.platform.UpdraftCoreInitializer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+object UpdraftAndroid {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var started = false
+
+ fun autoWire() {
+ if (started) return
+ started = true
+ scope.launch {
+ // Wait until the host app calls Updraft.start()
+ while (!runCatching { Updraft.settings }.isSuccess) delay(100)
+ Updraft.events.collect { event ->
+ val context = UpdraftContext.application
+ when (event) {
+ is UpdraftEvent.UpdateAvailable,
+ UpdraftEvent.ShowFeedbackHint,
+ UpdraftEvent.FeedbackDisabled,
+ -> UpdraftOverlayActivity.launch(context, event)
+
+ UpdraftEvent.FeedbackRequested ->
+ UpdraftFeedbackActivity.launch(context, Updraft.takePendingScreenshot())
+
+ UpdraftEvent.CloseFeedback, is UpdraftEvent.Error -> Unit
+ }
+ }
+ }
+ }
+}
+
+class UpdraftSdkInitializer : Initializer {
+ override fun create(context: Context): UpdraftAndroid {
+ UpdraftAndroid.autoWire()
+ return UpdraftAndroid
+ }
+
+ override fun dependencies(): List>> =
+ listOf(UpdraftCoreInitializer::class.java)
+}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftFeedbackActivity.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftFeedbackActivity.kt
new file mode 100644
index 0000000..7611eb3
--- /dev/null
+++ b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftFeedbackActivity.kt
@@ -0,0 +1,44 @@
+package com.appswithlove.updraft.android
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.ui.feedback.FeedbackScreen
+
+class UpdraftFeedbackActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ FeedbackScreen(
+ screenshotPng = pendingScreenshot,
+ onClose = { finish() },
+ )
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (!isChangingConfigurations) {
+ pendingScreenshot = null
+ Updraft.onFeedbackUiClosed()
+ }
+ }
+
+ companion object {
+ private var pendingScreenshot: ByteArray? = null
+
+ fun launch(context: Context, screenshotPng: ByteArray?) {
+ pendingScreenshot = screenshotPng
+ context.startActivity(
+ Intent(context, UpdraftFeedbackActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ (context as? Activity)?.overridePendingTransition(0, 0)
+ }
+ }
+}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftOverlayActivity.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftOverlayActivity.kt
new file mode 100644
index 0000000..dff80b3
--- /dev/null
+++ b/updraft-sdk/src/main/java/com/appswithlove/updraft/android/UpdraftOverlayActivity.kt
@@ -0,0 +1,49 @@
+package com.appswithlove.updraft.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.ui.dialogs.FeedbackDisabledDialog
+import com.appswithlove.updraft.ui.dialogs.FeedbackHintDialog
+import com.appswithlove.updraft.ui.dialogs.UpdateAvailableDialog
+import com.appswithlove.updraft.Updraft
+
+class UpdraftOverlayActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val event = pendingEvent
+ pendingEvent = null
+ if (event == null) {
+ finish()
+ return
+ }
+ setContent {
+ when (event) {
+ is UpdraftEvent.UpdateAvailable -> UpdateAvailableDialog(
+ event = event,
+ onOpen = { url -> Updraft.openUpdateUrl(url); finish() },
+ onLater = { finish() },
+ )
+ UpdraftEvent.ShowFeedbackHint -> FeedbackHintDialog(onDismiss = { finish() })
+ UpdraftEvent.FeedbackDisabled -> FeedbackDisabledDialog(onDismiss = { finish() })
+ else -> finish()
+ }
+ }
+ }
+
+ companion object {
+ private var pendingEvent: UpdraftEvent? = null
+
+ fun launch(context: Context, event: UpdraftEvent) {
+ pendingEvent = event
+ context.startActivity(
+ Intent(context, UpdraftOverlayActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+ }
+}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiException.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiException.kt
deleted file mode 100644
index bf9d898..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiException.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.appswithlove.updraft.api
-
-class ApiException(description: String) : Exception(description)
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiWrapper.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiWrapper.kt
deleted file mode 100644
index 2ca3a29..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/ApiWrapper.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package com.appswithlove.updraft.api
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.pm.PackageManager
-import android.os.Build
-import com.appswithlove.updraft.Settings
-import com.appswithlove.updraft.api.request.CheckLastVersionRequest
-import com.appswithlove.updraft.api.request.FeedbackEnabledRequest
-import com.appswithlove.updraft.api.request.FeedbackMobileRequest
-import com.appswithlove.updraft.api.request.GetLastVersionRequest
-import com.appswithlove.updraft.api.response.CheckLastVersionResponse
-import com.appswithlove.updraft.api.response.GetLastVersionResponse
-import com.appswithlove.updraft.feedback.form.FeedbackChoice
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.launch
-import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.MediaType.Companion.toMediaTypeOrNull
-import okhttp3.OkHttpClient
-import okhttp3.RequestBody
-import okhttp3.RequestBody.Companion.asRequestBody
-import okhttp3.RequestBody.Companion.toRequestBody
-import okhttp3.logging.HttpLoggingInterceptor
-import retrofit2.Retrofit
-import retrofit2.converter.kotlinx.serialization.asConverterFactory
-import java.io.File
-
-class ApiWrapper(
- private val context: Context,
- private val settings: Settings
-) {
- private val updraftService: UpdraftService
-
- init {
- val httpClientBuilder = OkHttpClient.Builder()
-
- val logging = HttpLoggingInterceptor().apply {
- level = when (settings.logLevel) {
- Settings.LOG_LEVEL_DEBUG -> HttpLoggingInterceptor.Level.BODY
- Settings.LOG_LEVEL_NONE, Settings.LOG_LEVEL_ERROR -> HttpLoggingInterceptor.Level.NONE
- else -> HttpLoggingInterceptor.Level.NONE
- }
- }
- httpClientBuilder.addInterceptor(logging)
-
- val json = Json { ignoreUnknownKeys = true }
- val retrofit = Retrofit.Builder()
- .baseUrl(settings.baseUrl)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .client(httpClientBuilder.build())
- .build()
-
- updraftService = retrofit.create(UpdraftService::class.java)
- }
-
- suspend fun getLastVersion(): GetLastVersionResponse {
- val request = GetLastVersionRequest(
- sdkKey = settings.sdkKey,
- appKey = settings.appKey,
- )
- return updraftService.getLastVersion(request)
- }
-
- suspend fun checkLastVersion(): CheckLastVersionResponse {
- val request = CheckLastVersionRequest(
- appKey = settings.appKey,
- sdkKey = settings.sdkKey,
- )
-
- val versionCode = try {
- val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- packageInfo.longVersionCode.toInt()
- } else {
- @Suppress("DEPRECATION")
- packageInfo.versionCode
- }
- } catch (e: PackageManager.NameNotFoundException) {
- e.printStackTrace()
- -1
- }
-
- if (versionCode == -1) throw IllegalStateException("Version code is invalid")
-
- return updraftService.checkLastVersion(request.copy(version = versionCode.toString()))
- }
-
- suspend fun isFeedbackEnabled(): Boolean {
- val request = FeedbackEnabledRequest(
- appKey = settings.appKey,
- sdkKey = settings.sdkKey,
- )
-
- val response = updraftService.isFeedbackEnabled(request)
- if (response.errorCodes.isNotEmpty()) {
- val errorDescription = response.errorDescriptions.firstOrNull().orEmpty()
- throw ApiException(errorDescription)
- }
- return response.isFeedbackEnabled
- }
-
- @SuppressLint("HardwareIds")
- fun sendMobileFeedback(
- feedbackChoice: FeedbackChoice,
- description: String,
- email: String,
- fileName: String
- ): Flow = callbackFlow {
- val request = FeedbackMobileRequest(
- appKey = settings.appKey,
- sdkKey = settings.sdkKey,
- tag = feedbackChoice.apiName(),
- image = fileName,
- description = description,
- email = email,
- buildVersion = try {
- context.packageManager.getPackageInfo(context.packageName, 0).versionName
- } catch (e: PackageManager.NameNotFoundException) {
- e.printStackTrace()
- ""
- },
- systemVersion = Build.VERSION.RELEASE,
- deviceName = Build.MODEL,
- deviceUuid = android.provider.Settings.Secure.getString(
- context.contentResolver,
- android.provider.Settings.Secure.ANDROID_ID
- ),
- )
-
- val multipartMap = mapMultipart(request) { bytesWritten, contentLength ->
- val progress = bytesWritten.toDouble() / contentLength.toDouble()
- trySend(progress).isSuccess
- }
-
- val uploadJob = launch {
- try {
- updraftService.feedbackMobile(multipartMap)
- close()
- } catch (t: Throwable) {
- if (settings.shouldShowErrors()) t.printStackTrace()
- close(t)
- }
- }
-
- awaitClose {
- uploadJob.cancel()
- }
- }
-
- private fun mapMultipart(
- request: FeedbackMobileRequest,
- listener: CountingRequestBody.Listener
- ): Map {
- val map = mutableMapOf()
- val imageFile = File(context.filesDir, request.image ?: "")
- val imageRequestBody = CountingRequestBody(
- imageFile.asRequestBody("image/png".toMediaTypeOrNull()),
- listener,
- )
- val plainText = "text/plain".toMediaTypeOrNull()
-
- map["image\"; filename=\"${imageFile.name}"] = imageRequestBody
-
- request.appKey?.let { map["app_key"] = it.toRequestBody(plainText) }
- request.sdkKey?.let { map["sdk_key"] = it.toRequestBody(plainText) }
- request.tag?.let { map["tag"] = it.toRequestBody(plainText) }
- request.description?.let { map["description"] = it.toRequestBody(plainText) }
- request.email?.let { map["email"] = it.toRequestBody(plainText) }
- request.buildVersion?.let { map["build_version"] = it.toRequestBody(plainText) }
- request.systemVersion?.let { map["system_version"] = it.toRequestBody(plainText) }
- request.deviceName?.let { map["device_name"] = it.toRequestBody(plainText) }
- request.deviceUuid?.let { map["device_uuid"] = it.toRequestBody(plainText) }
- request.navigationStack?.let { map["navigation_stack"] = it.toRequestBody(plainText) }
-
- return map
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/CountingRequestBody.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/api/CountingRequestBody.kt
deleted file mode 100644
index 0ddda8d..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/CountingRequestBody.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.appswithlove.updraft.api
-
-import android.util.Log
-import com.appswithlove.updraft.BuildConfig
-import okhttp3.MediaType
-import okhttp3.RequestBody
-import okio.Buffer
-import okio.BufferedSink
-import okio.ForwardingSink
-import okio.Sink
-import okio.buffer
-import java.io.IOException
-
-class CountingRequestBody(
- private val delegate: RequestBody,
- private val listener: Listener
-) : RequestBody() {
-
- override fun contentType(): MediaType? = delegate.contentType()
-
- override fun contentLength(): Long {
- return try {
- delegate.contentLength()
- } catch (e: IOException) {
- e.printStackTrace()
- -1
- }
- }
-
- @Throws(IOException::class)
- override fun writeTo(sink: BufferedSink) {
- if (BuildConfig.DEBUG) {
- Log.d("CountingRequestBody", "writeTo called")
- }
-
- val countingSink = CountingSink(sink)
- val bufferedSink = countingSink.buffer()
-
- delegate.writeTo(bufferedSink)
- bufferedSink.flush()
- }
-
- private inner class CountingSink(delegate: Sink) : ForwardingSink(delegate) {
- private var bytesWritten = 0L
-
- @Throws(IOException::class)
- override fun write(source: Buffer, byteCount: Long) {
- super.write(source, byteCount)
- bytesWritten += byteCount
- listener.onRequestProgress(bytesWritten, contentLength())
- if (BuildConfig.DEBUG) {
- Log.d(
- "CountingRequestBody",
- "bytes written: $bytesWritten content length: ${contentLength()}"
- )
- }
- }
- }
-
- fun interface Listener {
- fun onRequestProgress(bytesWritten: Long, contentLength: Long)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/UpdraftService.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/api/UpdraftService.kt
deleted file mode 100644
index 181a44a..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/api/UpdraftService.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.appswithlove.updraft.api
-
-import com.appswithlove.updraft.api.request.CheckLastVersionRequest
-import com.appswithlove.updraft.api.request.FeedbackEnabledRequest
-import com.appswithlove.updraft.api.request.GetLastVersionRequest
-import com.appswithlove.updraft.api.response.CheckLastVersionResponse
-import com.appswithlove.updraft.api.response.FeedbackEnabledResponse
-import com.appswithlove.updraft.api.response.FeedbackMobileResponse
-import com.appswithlove.updraft.api.response.GetLastVersionResponse
-import okhttp3.RequestBody
-import retrofit2.http.Body
-import retrofit2.http.Multipart
-import retrofit2.http.POST
-import retrofit2.http.PartMap
-
-interface UpdraftService {
-
- @POST("check_last_version/")
- suspend fun checkLastVersion(
- @Body checkLastVersionRequest: CheckLastVersionRequest,
- ): CheckLastVersionResponse
-
- @POST("get_last_version/")
- suspend fun getLastVersion(
- @Body getLastVersionRequest: GetLastVersionRequest,
- ): GetLastVersionResponse
-
- @Multipart
- @POST("feedback-mobile/")
- suspend fun feedbackMobile(
- @PartMap map: Map,
- ): FeedbackMobileResponse
-
- @POST("feedback-mobile-enabled/")
- suspend fun isFeedbackEnabled(
- @Body feedbackEnabledRequest: FeedbackEnabledRequest,
- ): FeedbackEnabledResponse
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackActivity.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackActivity.kt
deleted file mode 100644
index 351d365..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackActivity.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.appswithlove.updraft.feedback
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
-import com.appswithlove.updraft.Updraft
-import com.appswithlove.updraft.databinding.FeedbackActivityBinding
-import com.appswithlove.updraft.feedback.drawing.UpdraftDrawingFragment
-import com.appswithlove.updraft.feedback.form.FeedbackFormFragment
-
-class FeedbackActivity : AppCompatActivity(), FeedbackRootContainer {
-
- private var _binding: FeedbackActivityBinding? = null
- private val binding get() = _binding!!
-
- companion object {
- const val SAVED_SCREENSHOT = "UPDRAFT_SAVED_SCREENSHOT.png"
- private const val FILENAME_ARG = "filename"
-
- fun getIntent(context: Context, filename: String): Intent {
- return Intent(context, FeedbackActivity::class.java).apply {
- putExtra(FILENAME_ARG, filename)
- }
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- WindowCompat.setDecorFitsSystemWindows(window, false)
- _binding = FeedbackActivityBinding.inflate(layoutInflater)
- val rootView = binding.root
- setContentView(rootView)
-
- ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets ->
- val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- view.setPadding(
- systemBarsInsets.left,
- systemBarsInsets.top,
- systemBarsInsets.right,
- systemBarsInsets.bottom
- )
- insets
- }
-
- if (savedInstanceState == null) {
- val filename = intent.getStringExtra(FILENAME_ARG)
- supportFragmentManager.beginTransaction()
- .replace(
- binding.updraftFeedbackRootContainer.id,
- UpdraftDrawingFragment.getInstance(filename ?: "")
- )
- .commit()
- }
-
- binding.updraftCloseButton.setOnClickListener {
- finish()
- }
- }
-
- override fun onStart() {
- super.onStart()
- Updraft.getInstance()?.shakeDetectorManager?.stopListening()
- }
-
- override fun onStop() {
- Updraft.getInstance()?.shakeDetectorManager?.startListening()
- super.onStop()
- }
-
- override fun goNext() {
- supportFragmentManager.beginTransaction()
- .replace(binding.updraftFeedbackRootContainer.id, FeedbackFormFragment())
- .addToBackStack(null)
- .commit()
- }
-
- override fun goPrevious() {
- supportFragmentManager.popBackStack()
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackRootContainer.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackRootContainer.kt
deleted file mode 100644
index 29474e3..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/FeedbackRootContainer.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.appswithlove.updraft.feedback
-
-interface FeedbackRootContainer {
- fun goNext()
- fun goPrevious()
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/drawing/UpdraftDrawingFragment.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/drawing/UpdraftDrawingFragment.kt
deleted file mode 100644
index d97d960..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/drawing/UpdraftDrawingFragment.kt
+++ /dev/null
@@ -1,224 +0,0 @@
-package com.appswithlove.updraft.feedback.drawing
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Matrix
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import android.widget.ImageView
-import android.widget.RadioGroup
-import android.widget.Toast
-import androidx.core.content.res.ResourcesCompat
-import androidx.fragment.app.Fragment
-import com.appswithlove.updraft.R
-import com.appswithlove.updraft.feedback.FeedbackActivity
-import com.appswithlove.updraft.feedback.FeedbackRootContainer
-import com.rm.freedrawview.FreeDrawView
-import androidx.core.graphics.createBitmap
-import androidx.core.view.doOnLayout
-import com.appswithlove.updraft.databinding.FragmentUpdraftDrawingBinding
-
-class UpdraftDrawingFragment : Fragment() {
-
- companion object {
- private const val FILENAME_ARG = "filename"
-
- fun getInstance(fileName: String): UpdraftDrawingFragment {
- return UpdraftDrawingFragment().apply {
- arguments = Bundle().apply {
- putString(FILENAME_ARG, fileName)
- }
- }
- }
- }
-
- private var _binding: FragmentUpdraftDrawingBinding? = null
- private val binding get() = _binding!!
-
- private var feedbackRootContainer: FeedbackRootContainer? = null
- private var currentBitmap: Bitmap? = null
-
- private var fileName: String? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- fileName = arguments?.getString(FILENAME_ARG)
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- if (context is FeedbackRootContainer) {
- feedbackRootContainer = context
- }
- }
-
- override fun onDetach() {
- super.onDetach()
- feedbackRootContainer = null
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- _binding = FragmentUpdraftDrawingBinding.inflate(inflater, container, false)
- return binding.root
- }
-
- @SuppressLint("ClickableViewAccessibility")
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- with(binding) {
- updraftDrawingView.setPaintWidthPx(6f)
-
- val colorSelectRadioGroup: RadioGroup = binding.updraftColorSelectRadiogroup
- colorSelectRadioGroup.setOnCheckedChangeListener { group, checkedId ->
- when (checkedId) {
- updraftColorButtonBlack.id -> updraftDrawingView.setPaintColor(Color.BLACK)
- updraftColorButtonWhite.id -> updraftDrawingView.setPaintColor(Color.WHITE)
- updraftColorButtonYellow.id -> updraftDrawingView.setPaintColor(
- ResourcesCompat.getColor(resources, R.color.updraft_yellow, null)
- )
-
- updraftColorButtonRed.id -> updraftDrawingView.setPaintColor(
- ResourcesCompat.getColor(resources, R.color.updraft_red, null)
- )
- }
- }
-
- updraftColorSelectResetButton.setOnClickListener {
- updraftDrawingView.undoLast()
- }
-
- try {
- val inputStream = requireContext().openFileInput(fileName ?: return)
- currentBitmap = BitmapFactory.decodeStream(inputStream)
- inputStream.close()
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- updateBitmap()
-
- updraftFeedbackNextButton.setOnClickListener {
- updraftDrawingView.getDrawScreenshot(object : FreeDrawView.DrawCreatorListener {
- override fun onDrawCreated(draw: Bitmap) {
- saveBitmap(draw)
- }
-
- override fun onDrawCreationError() {
- saveBitmap(null)
- }
- })
- }
-
- val drawSomethingHereContainer = updraftDrawHereContainer
- drawSomethingHereContainer.setOnTouchListener { v, event ->
- if (event.action == MotionEvent.ACTION_DOWN) {
- v.performClick()
- v.visibility = View.GONE
- }
- false
- }
- }
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- _binding = null
- }
-
- private fun updateBitmap() {
- binding.updraftScreenshotBitmapHolder.doOnLayout { view ->
- val imageView = view as ImageView
- imageView.setImageBitmap(currentBitmap)
-
- val drawable = imageView.drawable ?: return@doOnLayout
-
- // Original image size
- val intrinsicWidth = drawable.intrinsicWidth
- val intrinsicHeight = drawable.intrinsicHeight
-
- // Actual ImageView size
- val imageViewWidth = imageView.width.toFloat()
- val imageViewHeight = imageView.height.toFloat()
-
- // ImageView scaleType
- val scale: Float
- val dx: Float
- val dy: Float
- if (intrinsicWidth * imageViewHeight > imageViewWidth * intrinsicHeight) {
- // Image is taller than the view
- scale = imageViewWidth / intrinsicWidth
- dx = 0f
- dy = (imageViewHeight - intrinsicHeight * scale) / 2f
- } else {
- // Image is wider than the view
- scale = imageViewHeight / intrinsicHeight
- dx = (imageViewWidth - intrinsicWidth * scale) / 2f
- dy = 0f
- }
-
- val displayedImageWidth = intrinsicWidth * scale
- val displayedImageHeight = intrinsicHeight * scale
-
- // Set FreeDrawView to match displayed area
- val params = binding.updraftDrawingView.layoutParams as FrameLayout.LayoutParams
- params.width = displayedImageWidth.toInt()
- params.height = displayedImageHeight.toInt()
- params.leftMargin = dx.toInt()
- params.topMargin = dy.toInt()
- binding.updraftDrawingView.layoutParams = params
- }
- }
-
- private fun saveBitmap(drawnBitmap: Bitmap?) {
- val baseBitmap = currentBitmap ?: return
- val bitmapConfig = baseBitmap.config ?: Bitmap.Config.ARGB_8888
-
- // Create a bitmap with the same size as the original
- val bmOverlay = createBitmap(baseBitmap.width, baseBitmap.height, bitmapConfig)
- val canvas = Canvas(bmOverlay)
-
- // Draw the base bitmap
- canvas.drawBitmap(baseBitmap, 0f, 0f, null)
-
- // Draw the overlay bitmap scaled to match the base bitmap
- drawnBitmap?.let { overlay ->
- if (overlay.width != baseBitmap.width || overlay.height != baseBitmap.height) {
- val matrix = Matrix().apply {
- val scaleX = baseBitmap.width.toFloat() / overlay.width
- val scaleY = baseBitmap.height.toFloat() / overlay.height
- postScale(scaleX, scaleY)
- }
- canvas.drawBitmap(overlay, matrix, null)
- } else {
- canvas.drawBitmap(overlay, 0f, 0f, null)
- }
- }
-
- try {
- requireContext().openFileOutput(FeedbackActivity.SAVED_SCREENSHOT, Context.MODE_PRIVATE)
- .use { stream ->
- bmOverlay.compress(Bitmap.CompressFormat.PNG, 100, stream)
- }
- bmOverlay.recycle()
- feedbackRootContainer?.goNext()
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(
- requireContext(),
- R.string.updraft_global_error,
- Toast.LENGTH_SHORT,
- ).show()
- }
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackChoice.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackChoice.kt
deleted file mode 100644
index d63e347..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackChoice.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.appswithlove.updraft.feedback.form
-
-class FeedbackChoice(
- val id: Long,
- val name: String,
- val isHiddenInDropdown: Boolean,
-) {
-
- fun id(): Long {
- return id
- }
-
- fun name(): String {
- return name
- }
-
- fun getIsHiddenInDropdown(): Boolean {
- return isHiddenInDropdown
- }
-
- fun apiName(): String? = when (id) {
- FEEDBACK_TYPE_DESIGN -> FEEDBACK_TYPE_DESIGN_API_STRING
- FEEDBACK_TYPE_FEEDBACK -> FEEDBACK_TYPE_FEEDBACK_API_STRING
- FEEDBACK_TYPE_BUG -> FEEDBACK_TYPE_BUG_API_STRING
- else -> null
- }
-
- companion object {
- const val FEEDBACK_TYPE_NOT_SELECTED = 0L
- const val FEEDBACK_TYPE_DESIGN = 1L
- const val FEEDBACK_TYPE_FEEDBACK = 2L
- const val FEEDBACK_TYPE_BUG = 3L
-
- private const val FEEDBACK_TYPE_DESIGN_API_STRING = "design"
- private const val FEEDBACK_TYPE_FEEDBACK_API_STRING = "feedback"
- private const val FEEDBACK_TYPE_BUG_API_STRING = "bug"
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormContract.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormContract.kt
deleted file mode 100644
index 58940c1..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormContract.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.appswithlove.updraft.feedback.form
-
-interface FeedbackFormContract {
-
- interface View {
- fun getSelectedChoice(): FeedbackChoice
- fun getEmail(): String
- fun getDescription(): String
-
- fun showProgress()
- fun hideProgress()
- fun showSuccessMessage()
- fun showErrorMessage(t: Throwable)
- fun updateProgress(progress: Double)
- fun closeFeedback()
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormFragment.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormFragment.kt
deleted file mode 100644
index 09b7eb6..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormFragment.kt
+++ /dev/null
@@ -1,161 +0,0 @@
-package com.appswithlove.updraft.feedback.form
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.AdapterView
-import androidx.fragment.app.Fragment
-import com.appswithlove.updraft.R
-import com.appswithlove.updraft.databinding.FragmentUpdraftFeedbackBinding
-import com.appswithlove.updraft.feedback.FeedbackRootContainer
-
-class FeedbackFormFragment : Fragment(), FeedbackFormContract.View {
-
- private var _binding: FragmentUpdraftFeedbackBinding? = null
- private val binding get() = _binding!!
-
- private lateinit var presenter: FeedbackFormPresenter
- private var feedbackRootContainer: FeedbackRootContainer? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- presenter = FeedbackFormPresenter()
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- if (context is FeedbackRootContainer) {
- feedbackRootContainer = context
- }
- }
-
- override fun onDetach() {
- super.onDetach()
- feedbackRootContainer = null
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- _binding = FragmentUpdraftFeedbackBinding.inflate(inflater, container, false)
- return binding.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- with(binding) {
- updraftFeedbackPreviousButton.setOnClickListener {
- feedbackRootContainer?.goPrevious()
- }
-
- val sendButton = updraftFeedbackSendButton
- sendButton.setOnClickListener { presenter.onSendButtonClicked() }
- sendButton.isEnabled = false
-
- val feedbackChoices = listOf(
- FeedbackChoice(
- FeedbackChoice.FEEDBACK_TYPE_NOT_SELECTED,
- getString(R.string.updraft_feedback_type_title),
- true
- ),
- FeedbackChoice(
- FeedbackChoice.FEEDBACK_TYPE_DESIGN,
- getString(R.string.updraft_feedback_type_design),
- false
- ),
- FeedbackChoice(
- FeedbackChoice.FEEDBACK_TYPE_FEEDBACK,
- getString(R.string.updraft_feedback_type_feedback),
- false
- ),
- FeedbackChoice(
- FeedbackChoice.FEEDBACK_TYPE_BUG,
- getString(R.string.updraft_feedback_type_bug),
- false
- )
- )
-
- val adapter = FeedbackFormTypeSpinnerAdapter(requireContext(), feedbackChoices)
- updraftFeedbackTypeSpinner.adapter = adapter
-
- updraftFeedbackTypeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(
- parent: AdapterView<*>,
- view: View?,
- position: Int,
- id: Long
- ) {
- val feedbackChoice = parent.selectedItem as FeedbackChoice
- sendButton.isEnabled = !feedbackChoice.isHiddenInDropdown
- }
-
- override fun onNothingSelected(parent: AdapterView<*>) {
- sendButton.isEnabled = false
- }
- }
-
- updraftFeedbackProgressCancel.setOnClickListener {
- presenter.onProgressCancelClicked()
- }
- }
-
- presenter.attachView(this)
- }
-
- override fun onDestroyView() {
- presenter.detachView()
- super.onDestroyView()
- _binding = null
- }
-
- override fun getSelectedChoice(): FeedbackChoice =
- binding.updraftFeedbackTypeSpinner.selectedItem as FeedbackChoice
-
- override fun getEmail(): String = binding.updraftFeedbackEmail.text.toString()
-
- override fun getDescription(): String = binding.updraftFeedbackDescription.text.toString()
-
- override fun showProgress() {
- binding.updraftFeedbackProgressContainer.visibility = View.VISIBLE
- }
-
- override fun hideProgress() {
- binding.updraftFeedbackProgressContainer.visibility = View.GONE
- }
-
- override fun showSuccessMessage() {
- with(binding) {
- updraftFeedbackProgressTitle.setText(R.string.updraft_feedback_send_success)
- updraftFeedbackProgressBar.visibility = View.VISIBLE
- updraftFeedbackProgressCancel.visibility = View.GONE
- }
- }
-
- override fun showErrorMessage(t: Throwable) {
- with(binding) {
- updraftFeedbackProgressTitle.setText(R.string.updraft_feedback_send_failure_title)
- updraftFeedbackProgressBar.visibility = View.GONE
- updraftFeedbackProgressText.setText(R.string.updraft_feedback_send_failure_description)
- }
- }
-
- @SuppressLint("SetTextI18n")
- override fun updateProgress(progress: Double) {
- with(binding) {
- updraftFeedbackProgressTitle.setText(R.string.updraft_feedback_send_inProgress)
- updraftFeedbackProgressBar.visibility = View.VISIBLE
- updraftFeedbackProgressBar.progress = (progress * 100).toInt()
- updraftFeedbackProgressText.text = "${(progress * 100).toInt()}%"
- }
- }
-
- override fun closeFeedback() {
- requireActivity().finish()
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormPresenter.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormPresenter.kt
deleted file mode 100644
index 36cf046..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormPresenter.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.appswithlove.updraft.feedback.form
-
-import com.appswithlove.updraft.Updraft
-import com.appswithlove.updraft.api.ApiWrapper
-import com.appswithlove.updraft.feedback.FeedbackActivity
-import kotlinx.coroutines.*
-import kotlin.coroutines.CoroutineContext
-
-class FeedbackFormPresenter : CoroutineScope {
-
- private var view: FeedbackFormContract.View? = null
- private val apiWrapper: ApiWrapper? = Updraft.getInstance()?.apiWrapper
-
- private var presenterJob: Job = SupervisorJob()
- override val coroutineContext: CoroutineContext
- get() = Dispatchers.Main + presenterJob
-
- private var sendJob: Job? = null
-
- fun attachView(view: FeedbackFormContract.View) {
- this.view = view
- }
-
- fun detachView() {
- cancel()
- view = null
- }
-
- fun onSendButtonClicked() {
- val currentView = view ?: return
- val api = apiWrapper ?: return
-
- currentView.showProgress()
-
- sendJob?.cancel()
- sendJob = launch {
- try {
- withContext(Dispatchers.IO) {
- api.sendMobileFeedback(
- currentView.getSelectedChoice(),
- currentView.getDescription(),
- currentView.getEmail(),
- FeedbackActivity.SAVED_SCREENSHOT
- ).collect { progress ->
- withContext(Dispatchers.Main) {
- currentView.updateProgress(progress)
- }
- }
- }
-
- withContext(Dispatchers.Main) {
- currentView.showSuccessMessage()
- closeAfterDelay()
- }
- } catch (_: CancellationException) {
- // Job was cancelled, ignore
- } catch (t: Throwable) {
- t.printStackTrace()
- withContext(Dispatchers.Main) {
- currentView.showErrorMessage(t)
- }
- }
- }
- }
-
- fun onProgressCancelClicked() {
- sendJob?.cancel()
- view?.hideProgress()
- }
-
- private fun closeAfterDelay() {
- launch {
- delay(1000)
- view?.closeFeedback()
- }
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormTypeSpinnerAdapter.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormTypeSpinnerAdapter.kt
deleted file mode 100644
index a62fa88..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/feedback/form/FeedbackFormTypeSpinnerAdapter.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.appswithlove.updraft.feedback.form
-
-import android.content.Context
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.BaseAdapter
-import android.widget.TextView
-import com.appswithlove.updraft.R
-
-class FeedbackFormTypeSpinnerAdapter(
- private val context: Context,
- private val feedbackChoices: List
-) : BaseAdapter() {
-
- override fun getCount(): Int = feedbackChoices.size
-
- override fun getItem(position: Int): Any = feedbackChoices[position]
-
- override fun getItemId(position: Int): Long = feedbackChoices[position].id
-
- override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
- val viewHolder: SelectedViewHolder
- val view: View
-
- if (convertView == null) {
- view = LayoutInflater.from(context)
- .inflate(R.layout.updraft_item_select_spinner, parent, false)
- viewHolder = SelectedViewHolder(view)
- view.tag = viewHolder
- } else {
- view = convertView
- viewHolder = view.tag as SelectedViewHolder
- }
-
- val currentItem = getItem(position) as FeedbackChoice
- viewHolder.title.text = currentItem.name
-
- return view
- }
-
- override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
- val viewHolder: DropdownViewHolder
- val view: View
-
- if (convertView == null) {
- view = LayoutInflater.from(context)
- .inflate(R.layout.updraft_item_dropdown_spinner, parent, false)
- viewHolder = DropdownViewHolder(view)
- view.tag = viewHolder
- } else {
- view = convertView
- viewHolder = view.tag as DropdownViewHolder
- }
-
- val currentItem = getItem(position) as FeedbackChoice
- viewHolder.title.text = currentItem.name
-
- val layoutParams = viewHolder.wholeView.layoutParams
- layoutParams.height = if (currentItem.isHiddenInDropdown) {
- 1
- } else {
- context.resources.getDimensionPixelOffset(R.dimen.updraft_dropdown_item_height)
- }
- viewHolder.wholeView.layoutParams = layoutParams
-
- return view
- }
-
- override fun getAutofillOptions(): Array = emptyArray()
-
- private class SelectedViewHolder(view: View) {
- val title: TextView = view.findViewById(R.id.updraft_selected_item_title)
- }
-
- private class DropdownViewHolder(view: View) {
- val wholeView: View = view
- val title: TextView = view.findViewById(R.id.updraft_dropdown_item_title)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt
deleted file mode 100644
index d14d7bd..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackEnabledInteractor.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.appswithlove.updraft.interactor
-
-import android.content.Context
-import android.content.SharedPreferences
-import com.appswithlove.updraft.api.ApiWrapper
-import androidx.core.content.edit
-
-class CheckFeedbackEnabledInteractor(
- private val apiWrapper: ApiWrapper,
- context: Context
-) {
-
- companion object {
- private const val FEEDBACK_ENABLED_STORAGE = "feedback_enabled_storage"
- private const val IS_FEEDBACK_ENABLED_PROPERTY = "is_feedback_enabled_property"
- }
-
- private val sharedPreferences: SharedPreferences =
- context.getSharedPreferences(FEEDBACK_ENABLED_STORAGE, Context.MODE_PRIVATE)
-
- suspend fun run(): CheckFeedbackResultModel {
- val isEnabled = apiWrapper.isFeedbackEnabled()
- val previouslyEnabled = sharedPreferences.getBoolean(IS_FEEDBACK_ENABLED_PROPERTY, false)
-
- val showAlert = when {
- !isEnabled && !previouslyEnabled -> false
- isEnabled && previouslyEnabled -> false
- else -> true
- }
-
- val alertType = if (!isEnabled && previouslyEnabled) {
- CheckFeedbackResultModel.ALERT_TYPE_FEEDBACK_DISABLED
- } else {
- CheckFeedbackResultModel.ALERT_TYPE_HOW_TO_GIVE_FEEDBACK
- }
-
- sharedPreferences.edit {
- putBoolean(IS_FEEDBACK_ENABLED_PROPERTY, isEnabled)
- }
-
- return CheckFeedbackResultModel(showAlert, alertType, isEnabled)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt
deleted file mode 100644
index f4d5d8a..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckFeedbackResultModel.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.appswithlove.updraft.interactor
-
-data class CheckFeedbackResultModel(
- val showAlert: Boolean,
- val alertType: Int,
- val isFeedbackEnabled: Boolean
-) {
- companion object {
- const val ALERT_TYPE_HOW_TO_GIVE_FEEDBACK = 0
- const val ALERT_TYPE_FEEDBACK_DISABLED = 1
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt
deleted file mode 100644
index 106e1b2..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateInteractor.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.appswithlove.updraft.interactor
-
-import com.appswithlove.updraft.api.ApiWrapper
-
-class CheckUpdateInteractor(private val apiWrapper: ApiWrapper) {
-
- suspend fun checkUpdate(): CheckUpdateResultModel {
- val checkLastVersionResponse = apiWrapper.checkLastVersion()
-
- val isAutoupdateEnabled = checkLastVersionResponse.isAutoupdateEnabled
- val isNewVersion = checkLastVersionResponse.isNewVersion
-
- return if (isNewVersion && isAutoupdateEnabled) {
- val getLastVersionResponse = apiWrapper.getLastVersion()
- val url = getLastVersionResponse.updateUrl
- CheckUpdateResultModel(
- showAlert = url != null,
- url = url,
- version = checkLastVersionResponse.version,
- yourVersion = checkLastVersionResponse.yourVersion,
- createAt = checkLastVersionResponse.createAt,
- )
- } else {
- CheckUpdateResultModel(showAlert = false)
- }
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt
deleted file mode 100644
index f51b40f..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/interactor/CheckUpdateResultModel.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.appswithlove.updraft.interactor
-
-data class CheckUpdateResultModel(
- var showAlert: Boolean = false,
- var url: String? = null,
- var version: String? = null,
- var yourVersion: String? = null,
- var createAt: String? = null,
-)
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/AppUpdateManager.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/AppUpdateManager.kt
deleted file mode 100644
index 02bc368..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/AppUpdateManager.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.appswithlove.updraft.manager
-
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ProcessLifecycleOwner
-import androidx.lifecycle.lifecycleScope
-import com.appswithlove.updraft.interactor.CheckUpdateInteractor
-import com.appswithlove.updraft.presentation.UpdraftSdkUi
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-
-/**
- * Created by satori on 3/27/18.
- */
-class AppUpdateManager(
- private val checkUpdateInteractor: CheckUpdateInteractor,
- private val updraftSdkUi: UpdraftSdkUi
-) : DefaultLifecycleObserver, UpdraftSdkUi.Listener {
-
- private var checkUpdateJob: Job? = null
-
- fun start() {
- ProcessLifecycleOwner.get().lifecycle.addObserver(this)
- }
-
- override fun onStart(owner: LifecycleOwner) {
- updraftSdkUi.showFeedbackAlert()
- updraftSdkUi.setListener(this)
-
- checkUpdateJob?.cancel()
- checkUpdateJob = owner.lifecycleScope.launch {
- try {
- val result = checkUpdateInteractor.checkUpdate()
- val url = result.url
- if (result.showAlert && url != null) {
- updraftSdkUi.showUpdateAvailableAlert(
- url = url,
- version = result.version,
- yourVersion = result.yourVersion,
- createAt = result.createAt,
- )
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
-
- override fun onStop(owner: LifecycleOwner) {
- updraftSdkUi.setListener(null)
- checkUpdateJob?.cancel()
- }
-
- override fun onOkClicked(url: String) {
- updraftSdkUi.openUrl(url)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CheckFeedbackEnabledManager.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CheckFeedbackEnabledManager.kt
deleted file mode 100644
index b45d5fd..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CheckFeedbackEnabledManager.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.appswithlove.updraft.manager
-
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ProcessLifecycleOwner
-import androidx.lifecycle.lifecycleScope
-import com.appswithlove.updraft.interactor.CheckFeedbackEnabledInteractor
-import com.appswithlove.updraft.interactor.CheckFeedbackResultModel
-import com.appswithlove.updraft.presentation.UpdraftSdkUi
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-
-class CheckFeedbackEnabledManager(
- private val updraftSdkUi: UpdraftSdkUi,
- private val checkFeedbackEnabledInteractor: CheckFeedbackEnabledInteractor
-) : DefaultLifecycleObserver, ShakeDetectorManager.ShakeDetectorListener {
-
- private var checkFeedbackJob: Job? = null
-
- fun start() {
- ProcessLifecycleOwner.get().lifecycle.addObserver(this)
- }
-
- override fun onStart(owner: LifecycleOwner) {
- super.onStart(owner)
- runFeedbackCheck(owner)
- }
-
- override fun onStop(owner: LifecycleOwner) {
- super.onStop(owner)
- checkFeedbackJob?.cancel()
- }
-
- private fun runFeedbackCheck(owner: LifecycleOwner) {
- checkFeedbackJob?.cancel()
-
- checkFeedbackJob = owner.lifecycleScope.launch {
- try {
- val checkFeedbackResultModel = checkFeedbackEnabledInteractor.run()
-
- if (!checkFeedbackResultModel.isFeedbackEnabled && updraftSdkUi.isCurrentlyShowingFeedback) {
- updraftSdkUi.closeFeedback()
- return@launch
- }
-
- if (checkFeedbackResultModel.showAlert) {
- when (checkFeedbackResultModel.alertType) {
- CheckFeedbackResultModel.ALERT_TYPE_FEEDBACK_DISABLED ->
- updraftSdkUi.showFeedbackDisabledAlert()
-
- CheckFeedbackResultModel.ALERT_TYPE_HOW_TO_GIVE_FEEDBACK ->
- updraftSdkUi.showHowToGiveFeedbackAlert()
- }
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
-
- override fun onShakeDetected() {
- updraftSdkUi.showFeedback()
-
- // Run feedback check again in a global lifecycle scope since Shake might happen outside lifecycle event
- ProcessLifecycleOwner.get().lifecycleScope.launch {
- try {
- val checkFeedbackResultModel = checkFeedbackEnabledInteractor.run()
-
- if (!checkFeedbackResultModel.isFeedbackEnabled && updraftSdkUi.isCurrentlyShowingFeedback) {
- updraftSdkUi.closeFeedback()
- return@launch
- }
-
- if (checkFeedbackResultModel.showAlert) {
- when (checkFeedbackResultModel.alertType) {
- CheckFeedbackResultModel.ALERT_TYPE_FEEDBACK_DISABLED ->
- updraftSdkUi.showFeedbackDisabledAlert()
-
- CheckFeedbackResultModel.ALERT_TYPE_HOW_TO_GIVE_FEEDBACK ->
- updraftSdkUi.showHowToGiveFeedbackAlert()
- }
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CurrentActivityManger.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CurrentActivityManger.kt
deleted file mode 100644
index 98b448b..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/CurrentActivityManger.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.appswithlove.updraft.manager
-
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.app.Application
-import android.os.Bundle
-
-@SuppressLint("StaticFieldLeak")
-class CurrentActivityManger private constructor() : Application.ActivityLifecycleCallbacks {
-
- companion object {
- val INSTANCE: CurrentActivityManger by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
- CurrentActivityManger()
- }
- }
-
- private var currentActivity: Activity? = null
-
- private val listeners: MutableSet = mutableSetOf()
-
- fun addListener(listener: CurrentActivityListener) {
- listeners.add(listener)
- dispatchCurrentActivityState()
- }
-
- fun removeListener(listener: CurrentActivityListener) {
- listeners.remove(listener)
- }
-
- override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
-
- override fun onActivityStarted(activity: Activity) {}
- override fun onActivityResumed(activity: Activity) {
- currentActivity = activity
- dispatchCurrentActivityState()
- }
-
- override fun onActivityPaused(activity: Activity) {
- currentActivity = null
- dispatchCurrentActivityState()
- }
-
- override fun onActivityStopped(activity: Activity) {}
- override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
- override fun onActivityDestroyed(activity: Activity) {}
-
- private fun dispatchCurrentActivityState() {
- val activity = currentActivity
- if (activity != null) {
- listeners.forEach { listener ->
- if (currentActivity != null) {
- listener.onActivityResumed(activity)
- } else {
- listener.onActivityPaused(activity)
- }
- }
- }
- }
-
- interface CurrentActivityListener {
- fun onActivityResumed(activity: Activity)
- fun onActivityPaused(activity: Activity)
- }
-}
\ No newline at end of file
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/SdkInitializer.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/SdkInitializer.kt
deleted file mode 100644
index 0f27ee0..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/SdkInitializer.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.appswithlove.updraft.manager
-
-import android.annotation.SuppressLint
-import android.app.Application
-import android.content.Context
-import androidx.startup.Initializer
-
-@SuppressLint("Unused")
-class SdkInitializer : Initializer {
- override fun create(context: Context): CurrentActivityManger {
- val activityManger = CurrentActivityManger.INSTANCE
- (context.applicationContext as? Application)?.registerActivityLifecycleCallbacks(activityManger)
- return activityManger
- }
-
- override fun dependencies(): List>> = emptyList()
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/ShakeDetectorManager.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/ShakeDetectorManager.kt
deleted file mode 100644
index 30cfb22..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/manager/ShakeDetectorManager.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-package com.appswithlove.updraft.manager
-
-import android.app.Application
-import android.hardware.Sensor
-import android.hardware.SensorEvent
-import android.hardware.SensorEventListener
-import android.hardware.SensorManager
-import android.util.Log
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ProcessLifecycleOwner
-import com.appswithlove.updraft.Settings
-
-class ShakeDetectorManager(
- application: Application,
- private val settings: Settings,
- private val shakeDetectorListener: ShakeDetectorListener
-) : DefaultLifecycleObserver, SensorEventListener {
-
- interface ShakeDetectorListener {
- fun onShakeDetected()
- }
-
- companion object {
- private const val SHAKE_THRESHOLD_GRAVITY = 2.7f
- private const val SHAKE_SLOP_TIME_MS = 500
- private const val SHAKE_COUNT_RESET_TIME_MS = 3000
- private const val UPD_TAG = "ShakeDetectorManager"
- }
-
- private val sensorManager =
- application.getSystemService(Application.SENSOR_SERVICE) as SensorManager
- private val accelerometer: Sensor? =
- sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
-
- private var shakeTimestamp: Long = 0
- private var shakeCount: Int = 0
- private var shouldListen = true
-
- fun start() {
- ProcessLifecycleOwner.get().lifecycle.addObserver(this)
- }
-
- override fun onStart(owner: LifecycleOwner) {
- super.onStart(owner)
- accelerometer?.let {
- sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
- }
- }
-
- override fun onStop(owner: LifecycleOwner) {
- super.onStop(owner)
- sensorManager.unregisterListener(this)
- }
-
- override fun onSensorChanged(event: SensorEvent) {
- val (x, y, z) = event.values
-
- val gX = x / SensorManager.GRAVITY_EARTH
- val gY = y / SensorManager.GRAVITY_EARTH
- val gZ = z / SensorManager.GRAVITY_EARTH
-
- val gForce = kotlin.math.sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()).toFloat()
-
-// if (settings.logLevel == Settings.LOG_LEVEL_DEBUG) {
-// Log.d(UPD_TAG, "Sensor change with gForce = $gForce")
-// }
-
- if (gForce > SHAKE_THRESHOLD_GRAVITY) {
- val now = System.currentTimeMillis()
-
- // Ignore shakes too close to each other
- if (shakeTimestamp + SHAKE_SLOP_TIME_MS > now) return
-
- // Reset count if no shakes in the last 3 seconds
- if (shakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
- shakeCount = 0
- }
-
- shakeTimestamp = now
- shakeCount++
- onShaken()
- }
- }
-
- override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
- // No-op
- }
-
- private fun onShaken() {
- if (shouldListen) {
- if (settings.logLevel == Settings.LOG_LEVEL_DEBUG) {
- Log.d(UPD_TAG, "Shake event detected")
- }
- shakeDetectorListener.onShakeDetected()
- stopListening()
- }
- }
-
- fun startListening() {
- shouldListen = true
- }
-
- fun stopListening() {
- shouldListen = false
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/DefaultScreenshotProvider.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/DefaultScreenshotProvider.kt
deleted file mode 100644
index 2aec17e..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/DefaultScreenshotProvider.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.appswithlove.updraft.presentation
-
-import android.app.Activity
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import androidx.core.graphics.createBitmap
-
-class DefaultScreenshotProvider : ScreenshotProvider {
- override fun getBitmap(activity: Activity): Bitmap? {
- val rootView = activity.window.decorView.rootView ?: return null
-
- val width = rootView.width
- val height = rootView.height
- if (width == 0 || height == 0) return null
-
- val bitmap = createBitmap(width, height)
- val canvas = Canvas(bitmap)
-
- rootView.draw(canvas)
-
- return bitmap
- }
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/ScreenshotProvider.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/ScreenshotProvider.kt
deleted file mode 100644
index 733ad75..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/ScreenshotProvider.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.appswithlove.updraft.presentation
-
-import android.app.Activity
-import android.graphics.Bitmap
-
-interface ScreenshotProvider {
- fun getBitmap(activity: Activity): Bitmap?
-}
diff --git a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/UpdraftSdkUi.kt b/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/UpdraftSdkUi.kt
deleted file mode 100644
index ed13a09..0000000
--- a/updraft-sdk/src/main/java/com/appswithlove/updraft/presentation/UpdraftSdkUi.kt
+++ /dev/null
@@ -1,325 +0,0 @@
-package com.appswithlove.updraft.presentation
-
-import android.app.Activity
-import android.content.Context
-import android.content.DialogInterface
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.Typeface
-import android.text.format.DateUtils
-import android.widget.TextView
-import android.widget.Toast
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
-import java.util.TimeZone
-import com.appswithlove.updraft.BuildConfig
-import com.appswithlove.updraft.R
-import com.appswithlove.updraft.Settings
-import com.appswithlove.updraft.Updraft.Companion.getInstance
-import com.appswithlove.updraft.feedback.FeedbackActivity
-import com.appswithlove.updraft.manager.CurrentActivityManger
-import androidx.appcompat.view.ContextThemeWrapper
-import com.google.android.material.R as MaterialR
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import java.io.IOException
-import androidx.core.net.toUri
-
-/**
- * Created by satori on 3/27/18.
- */
-class UpdraftSdkUi(
- currentActivityManger: CurrentActivityManger,
- private val mSettings: Settings,
- private val screenshotProvider: ScreenshotProvider,
-) : CurrentActivityManger.CurrentActivityListener {
- private var currentActivity: Activity? = null
- private var mShowDialogPending = false
- private var mPendingUrl: String? = null
- private var mPendingVersion: String? = null
- private var mPendingYourVersion: String? = null
- private var mPendingCreateAt: String? = null
- private var mListener: Listener? = null
- private var mFeedbackAlertShown = false
- var mUpdateAlertShown = false
- private set
- private var mShowStartAlertDialogPending = false
- private var mShowHowToFeedbackDialogPending = false
- private var mShowFeedbackDisabledDialogPending = false
- var isCurrentlyShowingFeedback = false
- private set
-
- init {
- currentActivityManger.addListener(this)
- }
-
- fun setListener(listener: Listener?) {
- mListener = listener
- }
-
- fun showFeedbackAlert() {
- if (!mSettings.showFeedbackAlert || !mSettings.feedbackEnabled) {
- return
- }
- if (!mFeedbackAlertShown) {
- val activity = currentActivity
- if (activity == null) {
- mShowStartAlertDialogPending = true
- return
- }
- mShowStartAlertDialogPending = false
- val builder = MaterialAlertDialogBuilder(activity.materialDialogContext(), R.style.Updraft_AlertDialog)
- builder.setTitle(R.string.updraft_feedbackDialog_title)
- builder.setMessage(R.string.updraft_feedbackDialog_description)
- builder.setCancelable(true)
- builder.setPositiveButton(R.string.updraft_button_ok) { dialog: DialogInterface, which: Int -> dialog.dismiss() }
- builder.show().boldTitle()
- mFeedbackAlertShown = true
- }
- }
-
- fun showUpdateAvailableAlert(
- url: String,
- version: String? = null,
- yourVersion: String? = null,
- createAt: String? = null,
- ) {
- val activity = currentActivity
- if (activity == null) {
- mShowDialogPending = true
- mPendingUrl = url
- mPendingVersion = version
- mPendingYourVersion = yourVersion
- mPendingCreateAt = createAt
- return
- }
-
- if (mUpdateAlertShown) {
- return
- }
-
- mShowDialogPending = false
- mPendingUrl = null
- mPendingVersion = null
- mPendingYourVersion = null
- mPendingCreateAt = null
-
- val title = if (!version.isNullOrBlank()) {
- activity.getString(R.string.updraft_updateAvailable_titleWithVersion, version)
- } else {
- activity.getString(R.string.updraft_updateAvailable_title)
- }
- val message = buildUpdateMessage(activity, yourVersion, createAt)
-
- val builder = MaterialAlertDialogBuilder(activity.materialDialogContext(), R.style.Updraft_AlertDialog)
- builder.setPositiveButton(R.string.updraft_updateAvailable_openButton) { dialog: DialogInterface?, id: Int ->
- if (mListener != null) {
- mListener?.onOkClicked(url)
- }
- }
- builder.setNegativeButton(R.string.updraft_updateAvailable_laterButton) { dialog: DialogInterface, which: Int -> dialog.dismiss() }
- builder.setCancelable(false)
- builder.setTitle(title)
- builder.setMessage(message)
- builder.show().boldTitle()
- mUpdateAlertShown = true
- }
-
- private fun buildUpdateMessage(
- context: Context,
- yourVersion: String?,
- createAt: String?,
- ): String {
- val age = formatRelativeAge(context, createAt)
- val hasVersion = !yourVersion.isNullOrBlank()
- return when {
- age != null && hasVersion ->
- context.getString(R.string.updraft_updateAvailable_descriptionFull, age, yourVersion)
- age != null ->
- context.getString(R.string.updraft_updateAvailable_releasedRelative, age)
- hasVersion ->
- context.getString(R.string.updraft_updateAvailable_yourVersion, yourVersion)
- else ->
- context.getString(R.string.updraft_updateAvailable_description)
- }
- }
-
- private fun formatRelativeAge(context: Context, createAt: String?): CharSequence? {
- if (createAt.isNullOrBlank()) return null
- val date = parseCreateAt(createAt) ?: return null
- val now = System.currentTimeMillis()
- val days = (now - date.time) / DateUtils.DAY_IN_MILLIS
- return when {
- now - date.time < DateUtils.MINUTE_IN_MILLIS ->
- context.getString(R.string.updraft_relative_justNow)
- days < 7L -> DateUtils.getRelativeTimeSpanString(
- date.time, now, DateUtils.MINUTE_IN_MILLIS,
- )
- days < 30L -> {
- val weeks = (days / 7L).toInt()
- context.resources.getQuantityString(R.plurals.updraft_relative_weeksAgo, weeks, weeks)
- }
- days < 365L -> {
- val months = (days / 30L).toInt()
- context.resources.getQuantityString(R.plurals.updraft_relative_monthsAgo, months, months)
- }
- else -> DateUtils.getRelativeTimeSpanString(date.time, now, DateUtils.DAY_IN_MILLIS)
- }
- }
-
- private fun parseCreateAt(createAt: String): Date? {
- val patterns = listOf(
- "yyyy-MM-dd'T'HH:mm:ss'Z'",
- "yyyy-MM-dd'T'HH:mm:ssXXX",
- "yyyy-MM-dd'T'HH:mm:ss",
- "yyyy-MM-dd HH:mm:ss",
- )
- for (pattern in patterns) {
- try {
- val format = SimpleDateFormat(pattern, Locale.US).apply {
- timeZone = TimeZone.getTimeZone("UTC")
- }
- return format.parse(createAt) ?: continue
- } catch (_: Exception) {
- // try next pattern
- }
- }
- return null
- }
-
- fun showFeedbackDisabledAlert() {
- val activity = currentActivity
- if (activity == null) {
- mShowFeedbackDisabledDialogPending = true
- return
- }
- mShowFeedbackDisabledDialogPending = false
- val builder = MaterialAlertDialogBuilder(activity.materialDialogContext(), R.style.Updraft_AlertDialog)
- builder.setNegativeButton(R.string.updraft_button_cancel) { dialog: DialogInterface, which: Int -> dialog.dismiss() }
- builder.setCancelable(false)
- builder.setTitle(R.string.updraft_feedbackDisabled_title)
- builder.setMessage(R.string.updraft_feedbackDisabled_description)
- builder.show().boldTitle()
- }
-
- fun showHowToGiveFeedbackAlert() {
- if (!mSettings.showFeedbackAlert) {
- return
- }
- val activity = currentActivity
- if (activity == null) {
- mShowHowToFeedbackDialogPending = true
- return
- }
- mShowHowToFeedbackDialogPending = false
- val builder = MaterialAlertDialogBuilder(activity.materialDialogContext(), R.style.Updraft_AlertDialog)
- builder.setTitle(R.string.updraft_feedbackDialog_title)
- builder.setMessage(R.string.updraft_feedbackDialog_description)
- builder.setPositiveButton(R.string.updraft_button_ok) { dialog: DialogInterface, which: Int -> dialog.dismiss() }
- builder.show().boldTitle()
- }
-
- fun openUrl(url: String?) {
- val uri = url?.toUri()
- val i = Intent(Intent.ACTION_VIEW, uri)
- if (currentActivity != null) {
- currentActivity!!.startActivity(i)
- }
- }
-
- fun showFeedback() {
- var activityStarted = false
- val activity = currentActivity
- if (activity != null) {
- val bitmap = screenshotProvider.getBitmap(activity)
- if (bitmap != null) {
- try {
- saveBitmap(bitmap)
- val intent = FeedbackActivity.getIntent(activity, FILENAME)
- activity.startActivity(intent)
- activity.overridePendingTransition(0, 0)
- activityStarted = true
- } catch (e: IOException) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
- Toast.makeText(activity, R.string.updraft_global_error, Toast.LENGTH_SHORT)
- .show()
- }
- }
- }
- if (!activityStarted) {
- getInstance()!!.shakeDetectorManager.startListening()
- }
- }
-
- @Throws(IOException::class)
- private fun saveBitmap(bitmap: Bitmap) {
- //Write file
- val stream = currentActivity!!.openFileOutput(FILENAME, Context.MODE_PRIVATE)
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
-
- //Cleanup
- stream.close()
- bitmap.recycle()
- }
-
-
- fun closeFeedback() {
- currentActivity.apply {
- if (this is FeedbackActivity && !this.isFinishing) {
- this.finish()
- }
- }
- }
-
- override fun onActivityResumed(activity: Activity) {
- currentActivity = activity
- if (mShowStartAlertDialogPending) {
- showFeedbackAlert()
- }
- if (mShowDialogPending) {
- showUpdateAvailableAlert(
- url = mPendingUrl.orEmpty(),
- version = mPendingVersion,
- yourVersion = mPendingYourVersion,
- createAt = mPendingCreateAt,
- )
- }
- if (mShowHowToFeedbackDialogPending) {
- showHowToGiveFeedbackAlert()
- }
- if (mShowFeedbackDisabledDialogPending) {
- showFeedbackDisabledAlert()
- }
- if (activity is FeedbackActivity) {
- isCurrentlyShowingFeedback = true
- }
- }
-
- override fun onActivityPaused(activity: Activity) {
- if (activity is FeedbackActivity) {
- isCurrentlyShowingFeedback = false
- }
- currentActivity = null
- }
-
- companion object {
- private const val TAG = "UpdraftSdkUi"
- private const val FILENAME = "UPDRAFT_SCREENSHOT.png"
- }
-
- interface Listener {
- fun onOkClicked(url: String)
- }
-}
-
-private fun androidx.appcompat.app.AlertDialog.boldTitle() {
- findViewById(androidx.appcompat.R.id.alertTitle)?.apply {
- typeface = Typeface.create(typeface, Typeface.BOLD)
- }
-}
-
-private fun Activity.materialDialogContext(): Context =
- ContextThemeWrapper(this, MaterialR.style.Theme_Material3_Light)
-
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawHelper.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawHelper.kt
deleted file mode 100644
index ff14a7b..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawHelper.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.rm.freedrawview
-
-import android.content.res.Resources
-import android.graphics.CornerPathEffect
-import android.graphics.ComposePathEffect
-import android.graphics.Paint
-import android.graphics.Paint.Cap
-import android.graphics.Paint.Join
-import android.graphics.Paint.Style
-
-object FreeDrawHelper {
-
- /**
- * Check if a list of points represents a single point (all points have same coordinates)
- */
- fun isAPoint(points: List): Boolean {
- if (points.isEmpty()) return false
- if (points.size == 1) return true
-
- for (i in 1 until points.size) {
- if (points[i - 1].x != points[i].x || points[i - 1].y != points[i].y) {
- return false
- }
- }
- return true
- }
-
- /**
- * Create, initialize, and setup a Paint
- */
- fun createPaintAndInitialize(
- paintColor: Int,
- paintAlpha: Int,
- paintWidth: Float,
- fill: Boolean
- ): Paint {
- val paint = createPaint()
- initializePaint(paint, paintColor, paintAlpha, paintWidth, fill)
- return paint
- }
-
- fun createPaint(): Paint = Paint(Paint.ANTI_ALIAS_FLAG)
-
- fun initializePaint(
- paint: Paint,
- paintColor: Int,
- paintAlpha: Int,
- paintWidth: Float,
- fill: Boolean
- ) {
- if (fill) {
- setupFillPaint(paint)
- } else {
- setupStrokePaint(paint)
- }
- paint.strokeWidth = paintWidth
- paint.color = paintColor
- paint.alpha = paintAlpha
- }
-
- fun setupFillPaint(paint: Paint) {
- paint.style = Style.FILL
- }
-
- fun setupStrokePaint(paint: Paint) {
- paint.strokeJoin = Join.ROUND
- paint.strokeCap = Cap.ROUND
- paint.pathEffect = ComposePathEffect(
- CornerPathEffect(100f),
- CornerPathEffect(100f)
- )
- paint.style = Style.STROKE
- }
-
-// fun copyFromPaint(from: Paint, to: Paint, copyWidth: Boolean) {
-// to.color = from.color
-// to.alpha = from.alpha
-// if (copyWidth) {
-// to.strokeWidth = from.strokeWidth
-// }
-// }
-
- fun copyFromValues(
- to: Paint,
- color: Int,
- alpha: Int,
- strokeWidth: Float,
- copyWidth: Boolean,
- ) {
- to.color = color
- to.alpha = alpha
- if (copyWidth) {
- to.strokeWidth = strokeWidth
- }
- }
-
- /** Convert dp to pixels **/
- fun convertDpToPixels(dp: Float): Float =
- dp * Resources.getSystem().displayMetrics.density
-
- /** Convert pixels to dp **/
- fun convertPixelsToDp(px: Float): Float =
- px / Resources.getSystem().displayMetrics.density
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSavedState.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSavedState.kt
deleted file mode 100644
index 59ccf45..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSavedState.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.rm.freedrawview
-
-import android.graphics.Paint
-import android.os.Build
-import android.os.Parcel
-import android.os.Parcelable
-import android.view.View
-import androidx.annotation.ColorInt
-import androidx.annotation.IntRange
-
-class FreeDrawSavedState(
- superState: Parcelable?,
- val paths: ArrayList,
- val canceledPaths: ArrayList,
- val paintWidth: Float,
- @ColorInt val paintColor: Int,
- @IntRange(from = 0, to = 255) val paintAlpha: Int,
- val resizeBehaviour: ResizeBehaviour,
- val lastDimensionW: Int,
- val lastDimensionH: Int
-) : View.BaseSavedState(superState) {
-
- constructor(parcel: Parcel) : this(
- superState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- parcel.readParcelable(Point::class.java.classLoader, Point::class.java)
- } else {
- @Suppress("DEPRECATION")
- parcel.readParcelable(Point::class.java.classLoader)
- },
- paths = parcel.createTypedArrayList(HistoryPath.CREATOR) ?: arrayListOf(),
- canceledPaths = parcel.createTypedArrayList(HistoryPath.CREATOR) ?: arrayListOf(),
- paintWidth = parcel.readFloat(),
- paintColor = parcel.readInt(),
- paintAlpha = parcel.readInt(),
- resizeBehaviour = ResizeBehaviour.entries[parcel.readInt()],
- lastDimensionW = parcel.readInt(),
- lastDimensionH = parcel.readInt()
- )
-
- fun getCurrentPaint(): Paint {
- val paint = FreeDrawHelper.createPaint()
- FreeDrawHelper.setupStrokePaint(paint)
- FreeDrawHelper.copyFromValues(
- to = paint,
- color = paintColor,
- alpha = paintAlpha,
- strokeWidth = paintWidth,
- copyWidth = true
- )
- return paint
- }
-
- fun getCurrentPaintWidth(): Float = paintWidth
-
- override fun writeToParcel(out: Parcel, flags: Int) {
- super.writeToParcel(out, flags)
- out.writeTypedList(paths)
- out.writeTypedList(canceledPaths)
- out.writeInt(paintColor)
- out.writeInt(paintAlpha)
- out.writeFloat(paintWidth)
- out.writeSerializable(resizeBehaviour)
- out.writeInt(lastDimensionW)
- out.writeInt(lastDimensionH)
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): FreeDrawSavedState =
- FreeDrawSavedState(parcel)
-
- override fun newArray(size: Int): Array = arrayOfNulls(size)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSerializableState.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSerializableState.kt
deleted file mode 100644
index a33625d..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawSerializableState.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.rm.freedrawview
-
-import java.io.Serializable
-
-class FreeDrawSerializableState(
- canceledPaths: ArrayList?,
- paths: ArrayList?,
- var paintColor: Int,
- var paintAlpha: Int,
- paintWidth: Float,
- var resizeBehaviour: ResizeBehaviour?,
- lastW: Int,
- lastH: Int
-) : Serializable {
-
- var canceledPaths: ArrayList = canceledPaths ?: ArrayList()
- var paths: ArrayList = paths ?: ArrayList()
- var paintWidth: Float = if (paintWidth >= 0) paintWidth else 0f
- var lastDimensionW: Int = if (lastW >= 0) lastW else 0
- var lastDimensionH: Int = if (lastH >= 0) lastH else 0
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawView.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawView.kt
deleted file mode 100644
index bd7c508..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/FreeDrawView.kt
+++ /dev/null
@@ -1,474 +0,0 @@
-package com.rm.freedrawview
-
-import android.content.Context
-import android.content.res.TypedArray
-import android.graphics.*
-import android.os.Parcelable
-import android.util.AttributeSet
-import android.view.MotionEvent
-import android.view.View
-import androidx.annotation.ColorInt
-import androidx.annotation.FloatRange
-import androidx.annotation.IntRange
-import com.appswithlove.updraft.R
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import androidx.core.graphics.createBitmap
-import kotlinx.coroutines.Job
-
-class FreeDrawView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : View(context, attrs, defStyleAttr), View.OnTouchListener {
-
- private var mCurrentPaint: Paint = FreeDrawHelper.createPaint()
- private var mCurrentPath: Path? = null
-
- private var mResizeBehaviour: ResizeBehaviour? = null
-
- private var mPoints = arrayListOf()
- private var mPaths = arrayListOf()
- private var mCanceledPaths = arrayListOf()
-
- @ColorInt
- private var mPaintColor: Int = DEFAULT_COLOR
-
- @IntRange(from = 0, to = 255)
- private var mPaintAlpha: Int = DEFAULT_ALPHA
-
- private var mLastDimensionW = -1
- private var mLastDimensionH = -1
-
- private var mFinishPath = false
-
- private var mPathDrawnListener: PathDrawnListener? = null
- private var mPathRedoUndoCountChangeListener: PathRedoUndoCountChangeListener? = null
-
- val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
-
- init {
- setOnTouchListener(this)
- var a: TypedArray? = null
- try {
- a = context.theme.obtainStyledAttributes(
- attrs,
- R.styleable.FreeDrawView,
- defStyleAttr,
- 0
- )
- initPaints(a)
- } finally {
- a?.recycle()
- }
- }
-
- override fun onSaveInstanceState(): Parcelable? {
- val superState = super.onSaveInstanceState()
- val resizeBehaviour = getResizeBehaviour() ?: return null
- if (mPoints.isNotEmpty()) {
- createHistoryPathFromPoints()
- }
- return FreeDrawSavedState(
- superState,
- mPaths,
- mCanceledPaths,
- getPaintWidth(),
- getPaintColor(),
- getPaintAlpha(),
- resizeBehaviour,
- mLastDimensionW,
- mLastDimensionH
- )
- }
-
- override fun onRestoreInstanceState(state: Parcelable?) {
- if (state !is FreeDrawSavedState) {
- super.onRestoreInstanceState(state)
- return
- }
-
- super.onRestoreInstanceState(state.superState)
- mPaths = state.paths
- mCanceledPaths = state.canceledPaths
- mCurrentPaint = state.getCurrentPaint()
- setPaintWidthPx(state.getCurrentPaintWidth())
- setPaintColor(state.paintColor)
- setPaintAlpha(state.paintAlpha)
- setResizeBehaviour(state.resizeBehaviour)
- mLastDimensionW = state.lastDimensionW
- mLastDimensionH = state.lastDimensionH
- notifyRedoUndoCountChanged()
- }
-
- fun setPaintColor(@ColorInt color: Int) {
- invalidate()
- mPaintColor = color
- mCurrentPaint.color = mPaintColor
- mCurrentPaint.alpha = mPaintAlpha
- }
-
- @ColorInt
- fun getPaintColor(): Int = mPaintColor
-
-// @ColorInt
-// fun getPaintColorWithAlpha(): Int = mCurrentPaint.color
-
- fun setPaintWidthPx(@FloatRange(from = 0.0) widthPx: Float) {
- if (widthPx > 0) {
- invalidate()
- mCurrentPaint.strokeWidth = widthPx
- }
- }
-
-// fun setPaintWidthDp(dp: Float) {
-// setPaintWidthPx(FreeDrawHelper.convertDpToPixels(dp))
-// }
-
- @FloatRange(from = 0.0)
- fun getPaintWidth(inDp: Boolean = false): Float =
- if (inDp) FreeDrawHelper.convertPixelsToDp(mCurrentPaint.strokeWidth)
- else mCurrentPaint.strokeWidth
-
- fun setPaintAlpha(@IntRange(from = 0, to = 255) alpha: Int) {
- invalidate()
- mPaintAlpha = alpha
- mCurrentPaint.alpha = mPaintAlpha
- }
-
- @IntRange(from = 0, to = 255)
- fun getPaintAlpha(): Int = mPaintAlpha
-
- fun setResizeBehaviour(newBehaviour: ResizeBehaviour?) {
- mResizeBehaviour = newBehaviour
- }
-
- fun getResizeBehaviour(): ResizeBehaviour? = mResizeBehaviour
-
- fun undoLast() {
- if (mPaths.isNotEmpty()) {
- mFinishPath = true
- invalidate()
- mCanceledPaths.add(mPaths.last())
- mPaths.removeAt(mPaths.size - 1)
- invalidate()
- notifyRedoUndoCountChanged()
- }
- }
-
-// fun redoLast() {
-// if (mCanceledPaths.isNotEmpty()) {
-// mPaths.add(mCanceledPaths.last())
-// mCanceledPaths.removeAt(mCanceledPaths.size - 1)
-// invalidate()
-// notifyRedoUndoCountChanged()
-// }
-// }
-//
-// fun undoAll() {
-// mPaths.reverse()
-// mCanceledPaths.addAll(mPaths)
-// mPaths.clear()
-// invalidate()
-// notifyRedoUndoCountChanged()
-// }
-//
-// fun redoAll() {
-// if (mCanceledPaths.isNotEmpty()) {
-// mPaths.addAll(mCanceledPaths)
-// mCanceledPaths.clear()
-// invalidate()
-// notifyRedoUndoCountChanged()
-// }
-// }
-
- fun getUndoCount(): Int = mPaths.size
- fun getRedoCount(): Int = mCanceledPaths.size
-
-// fun getPathCount(includeCurrentlyDrawingPath: Boolean): Int {
-// var size = mPaths.size
-// if (includeCurrentlyDrawingPath && mPoints.isNotEmpty()) size++
-// return size
-// }
-//
-// fun setOnPathDrawnListener(listener: PathDrawnListener?) {
-// mPathDrawnListener = listener
-// }
-//
-// fun removePathDrawnListener() {
-// mPathDrawnListener = null
-// }
-//
-// fun clearDrawAndHistory() {
-// clearDraw(false)
-// clearHistory(true)
-// }
-//
-// fun clearDraw() {
-// clearDraw(true)
-// }
-
-// private fun clearDraw(invalidate: Boolean) {
-// mPoints = arrayListOf()
-// mPaths = arrayListOf()
-// notifyRedoUndoCountChanged()
-// if (invalidate) invalidate()
-// }
-//
-// fun clearHistory() {
-// clearHistory(true)
-// }
-
-// private fun clearHistory(invalidate: Boolean) {
-// mCanceledPaths = arrayListOf()
-// notifyRedoUndoCountChanged()
-// if (invalidate) invalidate()
-// }
-//
-// fun setPathRedoUndoCountChangeListener(listener: PathRedoUndoCountChangeListener?) {
-// mPathRedoUndoCountChangeListener = listener
-// }
-//
-// fun removePathRedoUndoCountChangeListener() {
-// mPathRedoUndoCountChangeListener = null
-// }
-//
-// fun getCurrentViewStateAsSerializable(): FreeDrawSerializableState {
-// return FreeDrawSerializableState(
-// mCanceledPaths, mPaths, getPaintColor(),
-// getPaintAlpha(), getPaintWidth(), getResizeBehaviour(),
-// mLastDimensionW, mLastDimensionH
-// )
-// }
-//
-// fun restoreStateFromSerializable(state: FreeDrawSerializableState?) {
-// state ?: return
-// state.canceledPaths.let { mCanceledPaths = it }
-// state.paths.let { mPaths = it }
-// mPaintColor = state.paintColor
-// mPaintAlpha = state.paintAlpha
-// mCurrentPaint.color = state.paintColor
-// mCurrentPaint.alpha = state.paintAlpha
-// setPaintWidthPx(state.paintWidth)
-// mResizeBehaviour = state.resizeBehaviour
-// if (state.lastDimensionW >= 0) mLastDimensionW = state.lastDimensionW
-// if (state.lastDimensionH >= 0) mLastDimensionH = state.lastDimensionH
-// notifyRedoUndoCountChanged()
-// invalidate()
-// }
-
- fun getDrawScreenshot(listener: DrawCreatorListener) {
- takeScreenshot(listener)
- }
-
- private fun notifyPathStart() {
- mPathDrawnListener?.onPathStart()
- }
-
- private fun notifyPathDrawn() {
- mPathDrawnListener?.onNewPathDrawn()
- }
-
- private fun notifyRedoUndoCountChanged() {
- mPathRedoUndoCountChangeListener?.onRedoCountChanged(getRedoCount())
- mPathRedoUndoCountChangeListener?.onUndoCountChanged(getUndoCount())
- }
-
- private fun initPaints(a: TypedArray?) {
- mCurrentPaint = FreeDrawHelper.createPaint()
- mCurrentPaint.color =
- a?.getColor(R.styleable.FreeDrawView_paintColor, mPaintColor) ?: mPaintColor
- mCurrentPaint.alpha =
- a?.getInt(R.styleable.FreeDrawView_paintAlpha, mPaintAlpha) ?: mPaintAlpha
- mCurrentPaint.strokeWidth = a?.getDimensionPixelSize(
- R.styleable.FreeDrawView_paintWidth,
- FreeDrawHelper.convertDpToPixels(DEFAULT_STROKE_WIDTH).toInt()
- )?.toFloat() ?: FreeDrawHelper.convertDpToPixels(DEFAULT_STROKE_WIDTH)
- FreeDrawHelper.setupStrokePaint(mCurrentPaint)
-
- a?.let {
- val resizeBehaviour = it.getInt(R.styleable.FreeDrawView_resizeBehaviour, -1)
- mResizeBehaviour = when (resizeBehaviour) {
- 0 -> ResizeBehaviour.CLEAR
- 1 -> ResizeBehaviour.FIT_XY
- 2 -> ResizeBehaviour.CROP
- else -> ResizeBehaviour.CROP
- }
- }
- }
-
- private fun createAndCopyColorAndAlphaForFillPaint(from: Paint, copyWidth: Boolean): Paint {
- val paint = FreeDrawHelper.createPaint()
- FreeDrawHelper.setupFillPaint(paint)
- paint.color = from.color
- paint.alpha = from.alpha
- if (copyWidth) paint.strokeWidth = from.strokeWidth
- return paint
- }
-
- override fun onDraw(canvas: Canvas) {
- if (mPaths.isEmpty() && mPoints.isEmpty()) return
-
- val finishedPath = mFinishPath
- mFinishPath = false
-
- for (currentPath in mPaths) {
- if (currentPath.isPoint) {
- canvas.drawCircle(
- currentPath.originX,
- currentPath.originY,
- currentPath.getPaint().strokeWidth / 2,
- currentPath.getPaint()
- )
- } else {
- canvas.drawPath(currentPath.getPath(), currentPath.getPaint())
- }
- }
-
- if (mCurrentPath == null) mCurrentPath = Path() else mCurrentPath!!.rewind()
-
- when {
- mPoints.size == 1 || FreeDrawHelper.isAPoint(mPoints) -> {
- canvas.drawCircle(
- mPoints[0].x, mPoints[0].y,
- mCurrentPaint.strokeWidth / 2,
- createAndCopyColorAndAlphaForFillPaint(mCurrentPaint, false)
- )
- }
-
- mPoints.isNotEmpty() -> {
- var first = true
- for (point in mPoints) {
- if (first) {
- mCurrentPath!!.moveTo(point.x, point.y)
- first = false
- } else {
- mCurrentPath!!.lineTo(point.x, point.y)
- }
- }
- canvas.drawPath(mCurrentPath!!, mCurrentPaint)
- }
- }
-
- if (finishedPath && mPoints.isNotEmpty()) {
- createHistoryPathFromPoints()
- }
- }
-
- private fun createHistoryPathFromPoints() {
- mPaths.add(HistoryPath(mPoints, Paint(mCurrentPaint)))
- mPoints = arrayListOf()
- notifyPathDrawn()
- notifyRedoUndoCountChanged()
- }
-
- override fun onTouch(view: View?, motionEvent: MotionEvent): Boolean {
- if (motionEvent.action == MotionEvent.ACTION_DOWN) notifyPathStart()
- parent?.requestDisallowInterceptTouchEvent(true)
- mCanceledPaths = arrayListOf()
-
- mFinishPath =
- motionEvent.action == MotionEvent.ACTION_UP || motionEvent.action == MotionEvent.ACTION_CANCEL
-
- if (!mFinishPath) {
- for (i in 0 until motionEvent.historySize) {
- val point = Point()
- point.x = motionEvent.getHistoricalX(i)
- point.y = motionEvent.getHistoricalY(i)
- mPoints.add(point)
- }
- val point = Point()
- point.x = motionEvent.x
- point.y = motionEvent.y
- mPoints.add(point)
- }
-
- invalidate()
- return true
- }
-
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
- super.onSizeChanged(w, h, oldw, oldh)
- var xFactor = 1f
- var yFactor = 1f
- if (mLastDimensionW == -1) mLastDimensionW = w
- if (mLastDimensionH == -1) mLastDimensionH = h
- if (w >= 0 && w != oldw && w != mLastDimensionW) xFactor = w.toFloat() / mLastDimensionW
- if (h >= 0 && h != oldh && h != mLastDimensionH) yFactor = h.toFloat() / mLastDimensionH
- multiplyPathsAndPoints(xFactor, yFactor)
- }
-
- private fun multiplyPathsAndPoints(xFactor: Float, yFactor: Float) {
- if ((xFactor == 1f && yFactor == 1f) || xFactor <= 0 || yFactor <= 0 ||
- (mPaths.isEmpty() && mCanceledPaths.isEmpty() && mPoints.isEmpty())
- ) return
-
- if (mResizeBehaviour == ResizeBehaviour.CLEAR) {
- mPaths.clear()
- mCanceledPaths.clear()
- mPoints.clear()
- return
- }
-
- val xf = if (mResizeBehaviour == ResizeBehaviour.CROP) 1f else xFactor
- val yf = if (mResizeBehaviour == ResizeBehaviour.CROP) 1f else yFactor
-
- for (historyPath in mPaths + mCanceledPaths) {
- if (historyPath.isPoint) {
- historyPath.originX *= xf
- historyPath.originY *= yf
- } else {
- for (point in historyPath.points) {
- point.x *= xf
- point.y *= yf
- }
- }
- historyPath.generatePath()
- }
-
- for (point in mPoints) {
- point.x *= xf
- point.y *= yf
- }
- }
-
- interface DrawCreatorListener {
- fun onDrawCreated(draw: Bitmap)
- fun onDrawCreationError()
- }
-
- private fun takeScreenshot(listener: DrawCreatorListener) {
- val width = width
- val height = height
-
- coroutineScope.launch {
- val bitmap = withContext(Dispatchers.Default) {
- try {
- val bm = createBitmap(width, height)
- val canvas = Canvas(bm)
- canvas
- bm
- } catch (e: Exception) {
- e.printStackTrace()
- null
- }
- }
-
- if (bitmap != null) {
- val canvas = Canvas(bitmap)
- draw(canvas)
- listener.onDrawCreated(bitmap)
- } else {
- listener.onDrawCreationError()
- }
- }
- }
-
- companion object {
- private const val DEFAULT_STROKE_WIDTH = 4f
- private const val DEFAULT_COLOR = Color.BLACK
- private const val DEFAULT_ALPHA = 255
- }
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/HistoryPath.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/HistoryPath.kt
deleted file mode 100644
index 8bf2a63..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/HistoryPath.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.rm.freedrawview
-
-import android.graphics.Paint
-import android.graphics.Path
-import android.os.Parcel
-import android.os.Parcelable
-import java.io.Serializable
-
-class HistoryPath(
- points: ArrayList,
- paint: Paint
-) : Parcelable, Serializable {
-
- companion object {
- private const val serialVersionUID = 41L
-
- @JvmField
- val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): HistoryPath = HistoryPath(parcel)
- override fun newArray(size: Int): Array = arrayOfNulls(size)
- }
- }
-
- var points: ArrayList = ArrayList(points)
- var paintColor: Int = paint.color
- var paintAlpha: Int = paint.alpha
- var paintWidth: Float = paint.strokeWidth
- var originX: Float = points[0].x
- var originY: Float = points[0].y
- var isPoint: Boolean = FreeDrawHelper.isAPoint(points)
-
- @Transient
- private var path: Path? = null
-
- @Transient
- private var paintObj: Paint? = null
-
- init {
- generatePath()
- generatePaint()
- }
-
- fun generatePath() {
- path = Path()
- points.forEachIndexed { index, point ->
- if (index == 0) path?.moveTo(point.x, point.y)
- else path?.lineTo(point.x, point.y)
- }
- }
-
- private fun generatePaint() {
- paintObj =
- FreeDrawHelper.createPaintAndInitialize(paintColor, paintAlpha, paintWidth, isPoint)
- }
-
- fun getPath(): Path {
- if (path == null) generatePath()
- return path!!
- }
-
- fun getPaint(): Paint {
- if (paintObj == null) generatePaint()
- return paintObj!!
- }
-
- private constructor(parcel: Parcel) : this(
- ArrayList().apply { parcel.readTypedList(this, Point.CREATOR) },
- Paint()
- ) {
- paintColor = parcel.readInt()
- paintAlpha = parcel.readInt()
- paintWidth = parcel.readFloat()
- originX = parcel.readFloat()
- originY = parcel.readFloat()
- isPoint = parcel.readByte() != 0.toByte()
-
- generatePath()
- generatePaint()
- }
-
- override fun writeToParcel(parcel: Parcel, flags: Int) {
- parcel.writeTypedList(points)
- parcel.writeInt(paintColor)
- parcel.writeInt(paintAlpha)
- parcel.writeFloat(paintWidth)
- parcel.writeFloat(originX)
- parcel.writeFloat(originY)
- parcel.writeByte(if (isPoint) 1 else 0)
- }
-
- override fun describeContents(): Int = 0
-
- override fun toString(): String {
- return "Point: $isPoint\nPoints: $points\nColor: $paintColor\nAlpha: $paintAlpha\nWidth: $paintWidth"
- }
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/PathDrawnListener.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/PathDrawnListener.kt
deleted file mode 100644
index 4fc5e73..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/PathDrawnListener.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.rm.freedrawview
-
-interface PathDrawnListener {
- fun onPathStart()
-
- fun onNewPathDrawn()
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/PathRedoUndoCountChangeListener.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/PathRedoUndoCountChangeListener.kt
deleted file mode 100644
index 5f0d143..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/PathRedoUndoCountChangeListener.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.rm.freedrawview
-
-interface PathRedoUndoCountChangeListener {
- fun onUndoCountChanged(undoCount: Int)
-
- fun onRedoCountChanged(redoCount: Int)
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/Point.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/Point.kt
deleted file mode 100644
index 888a55b..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/Point.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.rm.freedrawview
-
-import android.os.Parcel
-import android.os.Parcelable
-import java.io.Serializable
-
-class Point() : Parcelable, Serializable {
-
- var x: Float = -1f
- var y: Float = -1f
-
- override fun toString(): String {
- return "$x : $y - "
- }
-
- private constructor(parcel: Parcel) : this() {
- x = parcel.readFloat()
- y = parcel.readFloat()
- }
-
- override fun describeContents(): Int = 0
-
- override fun writeToParcel(dest: Parcel, flags: Int) {
- dest.writeFloat(x)
- dest.writeFloat(y)
- }
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): Point = Point(parcel)
- override fun newArray(size: Int): Array = arrayOfNulls(size)
- }
-}
diff --git a/updraft-sdk/src/main/java/com/rm/freedrawview/ResizeBehaviour.kt b/updraft-sdk/src/main/java/com/rm/freedrawview/ResizeBehaviour.kt
deleted file mode 100644
index 047e777..0000000
--- a/updraft-sdk/src/main/java/com/rm/freedrawview/ResizeBehaviour.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.rm.freedrawview
-
-/**
- * When RMFreeDrawView dimensions are changed, you can apply one of the following behaviours
- * {@link #CLEAR} - It just clear the View from every previous paint
- * {@link #FIT_XY} - It stretch the content to fit the new dimensions
- * {@link #CROP} - Keep the exact position of the previous point, if the dimensions changes, there
- * may be points outside the view and not visible
- */
-enum class ResizeBehaviour {
- CLEAR,
- FIT_XY,
- CROP
-}
diff --git a/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_2_white.png b/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_2_white.png
deleted file mode 100644
index a48ce15..0000000
Binary files a/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_2_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_color.png b/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_color.png
deleted file mode 100644
index b408f39..0000000
Binary files a/updraft-sdk/src/main/res/drawable-hdpi/icn_exit_color.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-hdpi/updraft_logo_white.png b/updraft-sdk/src/main/res/drawable-hdpi/updraft_logo_white.png
deleted file mode 100644
index 2422d8d..0000000
Binary files a/updraft-sdk/src/main/res/drawable-hdpi/updraft_logo_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_2_white.png b/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_2_white.png
deleted file mode 100644
index b23dde1..0000000
Binary files a/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_2_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_color.png b/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_color.png
deleted file mode 100644
index 9c4caf3..0000000
Binary files a/updraft-sdk/src/main/res/drawable-mdpi/icn_exit_color.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-mdpi/updraft_logo_white.png b/updraft-sdk/src/main/res/drawable-mdpi/updraft_logo_white.png
deleted file mode 100644
index dab07ac..0000000
Binary files a/updraft-sdk/src/main/res/drawable-mdpi/updraft_logo_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_2_white.png b/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_2_white.png
deleted file mode 100644
index c9e7297..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_2_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_color.png b/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_color.png
deleted file mode 100644
index b3a4ede..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xhdpi/icn_exit_color.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xhdpi/updraft_logo_white.png b/updraft-sdk/src/main/res/drawable-xhdpi/updraft_logo_white.png
deleted file mode 100644
index 04889e5..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xhdpi/updraft_logo_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_2_white.png b/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_2_white.png
deleted file mode 100644
index 990167f..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_2_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_color.png b/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_color.png
deleted file mode 100644
index d3a8a25..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xxhdpi/icn_exit_color.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable-xxhdpi/updraft_logo_white.png b/updraft-sdk/src/main/res/drawable-xxhdpi/updraft_logo_white.png
deleted file mode 100644
index fbc8566..0000000
Binary files a/updraft-sdk/src/main/res/drawable-xxhdpi/updraft_logo_white.png and /dev/null differ
diff --git a/updraft-sdk/src/main/res/drawable/exit_icon.xml b/updraft-sdk/src/main/res/drawable/exit_icon.xml
deleted file mode 100644
index 6bf4706..0000000
--- a/updraft-sdk/src/main/res/drawable/exit_icon.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- -
-
-
-
-
-
- -
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_black_button.xml b/updraft-sdk/src/main/res/drawable/updraft_black_button.xml
deleted file mode 100644
index 23c5555..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_black_button.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_black_circle.xml b/updraft-sdk/src/main/res/drawable/updraft_black_circle.xml
deleted file mode 100644
index cdb39c6..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_black_circle.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/updraft-sdk/src/main/res/drawable/updraft_black_circle_selected.xml b/updraft-sdk/src/main/res/drawable/updraft_black_circle_selected.xml
deleted file mode 100644
index 7095465..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_black_circle_selected.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
- -
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_progress_drawable.xml b/updraft-sdk/src/main/res/drawable/updraft_progress_drawable.xml
deleted file mode 100644
index 383fc3e..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_progress_drawable.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_red_button.xml b/updraft-sdk/src/main/res/drawable/updraft_red_button.xml
deleted file mode 100644
index bc38ce3..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_red_button.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_red_circle.xml b/updraft-sdk/src/main/res/drawable/updraft_red_circle.xml
deleted file mode 100644
index dd33e93..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_red_circle.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_red_circle_selected.xml b/updraft-sdk/src/main/res/drawable/updraft_red_circle_selected.xml
deleted file mode 100644
index 75435b9..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_red_circle_selected.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
- -
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_white_button.xml b/updraft-sdk/src/main/res/drawable/updraft_white_button.xml
deleted file mode 100644
index 60662bf..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_white_button.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_white_circle.xml b/updraft-sdk/src/main/res/drawable/updraft_white_circle.xml
deleted file mode 100644
index faa7f45..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_white_circle.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_white_circle_selected.xml b/updraft-sdk/src/main/res/drawable/updraft_white_circle_selected.xml
deleted file mode 100644
index 005a0f2..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_white_circle_selected.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- -
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_yellow_button.xml b/updraft-sdk/src/main/res/drawable/updraft_yellow_button.xml
deleted file mode 100644
index 977e7b0..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_yellow_button.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_yellow_circle.xml b/updraft-sdk/src/main/res/drawable/updraft_yellow_circle.xml
deleted file mode 100644
index f2aaa77..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_yellow_circle.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/updraft_yellow_circle_selected.xml b/updraft-sdk/src/main/res/drawable/updraft_yellow_circle_selected.xml
deleted file mode 100644
index 80c2869..0000000
--- a/updraft-sdk/src/main/res/drawable/updraft_yellow_circle_selected.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
- -
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/drawable/white_box_edit_text_background.xml b/updraft-sdk/src/main/res/drawable/white_box_edit_text_background.xml
deleted file mode 100644
index cf2a7a1..0000000
--- a/updraft-sdk/src/main/res/drawable/white_box_edit_text_background.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/layout/feedback_activity.xml b/updraft-sdk/src/main/res/layout/feedback_activity.xml
deleted file mode 100644
index db9e057..0000000
--- a/updraft-sdk/src/main/res/layout/feedback_activity.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/updraft-sdk/src/main/res/layout/fragment_updraft_drawing.xml b/updraft-sdk/src/main/res/layout/fragment_updraft_drawing.xml
deleted file mode 100644
index 28c53f0..0000000
--- a/updraft-sdk/src/main/res/layout/fragment_updraft_drawing.xml
+++ /dev/null
@@ -1,115 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/layout/fragment_updraft_feedback.xml b/updraft-sdk/src/main/res/layout/fragment_updraft_feedback.xml
deleted file mode 100644
index a492875..0000000
--- a/updraft-sdk/src/main/res/layout/fragment_updraft_feedback.xml
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/layout/updraft_item_dropdown_spinner.xml b/updraft-sdk/src/main/res/layout/updraft_item_dropdown_spinner.xml
deleted file mode 100644
index 3943417..0000000
--- a/updraft-sdk/src/main/res/layout/updraft_item_dropdown_spinner.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/layout/updraft_item_select_spinner.xml b/updraft-sdk/src/main/res/layout/updraft_item_select_spinner.xml
deleted file mode 100644
index 36b3c8c..0000000
--- a/updraft-sdk/src/main/res/layout/updraft_item_select_spinner.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/values-de/strings.xml b/updraft-sdk/src/main/res/values-de/strings.xml
deleted file mode 100644
index 3ccaf85..0000000
--- a/updraft-sdk/src/main/res/values-de/strings.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- Abbrechen
- Nächste
- OK
- Vorherige
- Feedback senden
- Beschreiben Sie das Thema...
- Draw\nsomething\nhere...
- Ihre E-Mail
- Beim Senden des Feedbacks ist ein Fehler aufgetreten.
- Feedback kann nicht gesendet werden
- Senden...
- Vielen Dank für Ihr Feedback
- Feedback geben
- Fehler
- Entwurf
- Rückmeldung
- Art der Rückmeldung
- Schütteln Sie das Smartphone, um ein Feedback zu geben oder einen Fehler zu melden.
- Feedback geben
- Die Feedback-Funktion wurde deaktiviert. Sie können nicht länger ein Feedback senden oder einen Fehler melden, indem Sie das Smartphone schütteln.
- Feedback Deaktiviert
- Fehler
- Möchten Sie die neue Version herunterladen?
- Veröffentlicht %1$s. Sie verwenden aktuell Version %2$s.
- Später
- Öffnen
- Veröffentlicht %1$s.
- Neue Version verfügbar
- Neue Version (%1$s) verfügbar
- Sie verwenden aktuell Version %1$s.
- gerade eben
-
- - vor %d Woche
- - vor %d Wochen
-
-
- - vor %d Monat
- - vor %d Monaten
-
-
diff --git a/updraft-sdk/src/main/res/values/attrs.xml b/updraft-sdk/src/main/res/values/attrs.xml
deleted file mode 100644
index 7ef2e3d..0000000
--- a/updraft-sdk/src/main/res/values/attrs.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/values/colors.xml b/updraft-sdk/src/main/res/values/colors.xml
deleted file mode 100644
index a2e76b4..0000000
--- a/updraft-sdk/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #38383c
- #f6b33e
- #eb6764
- #4a90e2
- #00000000
- #a6a6a6
- #b338383c
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/values/constants.xml b/updraft-sdk/src/main/res/values/constants.xml
deleted file mode 100644
index 2b602a1..0000000
--- a/updraft-sdk/src/main/res/values/constants.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- updraftsdk
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/values/dimens.xml b/updraft-sdk/src/main/res/values/dimens.xml
deleted file mode 100644
index 26bfd6c..0000000
--- a/updraft-sdk/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
- 32dp
- 32dp
- 16dp
- 40dp
- 38dp
- 1dp
- 8dp
- 48dp
- 16dp
- 16dp
- 60dp
- 30dp
- 20dp
- 20dp
- 44dp
- 20dp
- 17dp
- 20dp
- 17dp
- 6dp
- 10dp
- 6dp
- 10dp
- 16sp
- 40dp
- 16dp
- 16dp
- 30dp
- 50dp
- 16dp
- 16dp
- 27dp
- 27dp
- 10dp
- 224dp
-
\ No newline at end of file
diff --git a/updraft-sdk/src/main/res/values/strings.xml b/updraft-sdk/src/main/res/values/strings.xml
deleted file mode 100644
index 5b2534d..0000000
--- a/updraft-sdk/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- Cancel
- Next
- OK
- Previous
- Send Feedback
- Describe the issue...\nBrevity is the source of wit
- Draw\nsomething\nhere...
- Your Email
- An error occurred when sending feedback.
- Failed to send feedback
- Sending...
- Thank you for the feedback
- Give Feedback
- Bug
- Design
- Feedback
- Type of Feedback
- Shake the device to give a feedback or report a bug.
- Give Feedback
- Feedback functionality has been disabled. You can no longer send a feedback or report a bug by shaking the device.
- Feedback Disabled
- Error
- You will be redirected to download the new version of the app
- Released %1$s. You\'re currently on version %2$s.
- Later
- Open
- Released %1$s.
- New version available
- New version (%1$s) available
- You\'re currently on version %1$s.
- just now
-
- - %d week ago
- - %d weeks ago
-
-
- - %d month ago
- - %d months ago
-
-
diff --git a/updraft-sdk/src/main/res/values/styles.xml b/updraft-sdk/src/main/res/values/styles.xml
deleted file mode 100644
index 4ba2fe6..0000000
--- a/updraft-sdk/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,68 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/updraft-ui-compose/build.gradle.kts b/updraft-ui-compose/build.gradle.kts
new file mode 100644
index 0000000..9fc1f45
--- /dev/null
+++ b/updraft-ui-compose/build.gradle.kts
@@ -0,0 +1,84 @@
+import java.util.Properties
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.loco)
+ alias(libs.plugins.maven.publish)
+}
+
+val localProperties = Properties().apply {
+ val file = rootProject.file("local.properties")
+ if (file.exists()) file.inputStream().use { load(it) }
+}
+
+Loco {
+ config {
+ apiKey = localProperties.getProperty("updraft.locoApiKey").orEmpty()
+ lang = listOf("en", "de")
+ defLang = "en"
+ resDir = "$projectDir/src/commonMain/composeResources"
+ fallbackLang = "en"
+ orderByAssetId = true
+ hideComments = true
+ }
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+ }
+
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ iosX64(),
+ ).forEach { target ->
+ target.binaries.framework {
+ baseName = "UpdraftUI"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":updraft-core"))
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(compose.components.resources)
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
+}
+
+compose.resources {
+ packageOfResClass = "com.appswithlove.updraft.ui.resources"
+ publicResClass = false
+}
+
+android {
+ namespace = "com.appswithlove.updraft.ui"
+ compileSdk = 36
+ defaultConfig {
+ minSdk = 23
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+mavenPublishing {
+ publishToMavenCentral(automaticRelease = true)
+ signAllPublications()
+}
diff --git a/updraft-ui-compose/gradle.properties b/updraft-ui-compose/gradle.properties
new file mode 100644
index 0000000..d26d171
--- /dev/null
+++ b/updraft-ui-compose/gradle.properties
@@ -0,0 +1,20 @@
+GROUP=com.appswithlove.updraft
+POM_ARTIFACT_ID=updraft-ui-compose
+VERSION_NAME=2.0.0
+
+POM_NAME=updraft-ui-compose
+POM_DESCRIPTION=Compose Multiplatform UI components for the Updraft SDK
+POM_INCEPTION_YEAR=2020
+
+POM_URL=https://github.com/appswithlove/updraft-sdk
+POM_SCM_URL=https://github.com/appswithlove/updraft-sdk/
+POM_SCM_CONNECTION=scm:git:git://github.com/appswithlove/updraft-sdk.git
+POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/appswithlove/updraft-sdk.git
+
+POM_LICENSE_NAME=The Apache Software License, Version 2.0
+POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt
+POM_LICENSE_DIST=repo
+
+POM_DEVELOPER_ID=yannickpulver
+POM_DEVELOPER_NAME=Yannick Pulver
+POM_DEVELOPER_URL=https://github.com/yannickpulver/
diff --git a/updraft-ui-compose/src/androidMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.android.kt b/updraft-ui-compose/src/androidMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.android.kt
new file mode 100644
index 0000000..6e67d92
--- /dev/null
+++ b/updraft-ui-compose/src/androidMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.android.kt
@@ -0,0 +1,40 @@
+package com.appswithlove.updraft.ui.feedback
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.IntSize
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+import java.io.ByteArrayOutputStream
+
+actual fun decodePng(bytes: ByteArray): ImageBitmap =
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size).asImageBitmap()
+
+actual fun renderAnnotated(base: ByteArray, paths: List, canvasSize: IntSize): ByteArray {
+ val bitmap = BitmapFactory.decodeByteArray(base, 0, base.size)
+ .copy(Bitmap.Config.ARGB_8888, true)
+ val canvas = Canvas(bitmap)
+ paths.forEach { drawn ->
+ val paint = Paint().apply {
+ color = drawn.color.toArgb()
+ strokeWidth = drawn.strokeWidthPx
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ isAntiAlias = true
+ }
+ val mappedPoints = drawn.points.map { mapToBitmapSpace(it, canvasSize, bitmap.width, bitmap.height) }
+ val path = Path()
+ mappedPoints.firstOrNull()?.let { path.moveTo(it.x, it.y) }
+ mappedPoints.drop(1).forEach { path.lineTo(it.x, it.y) }
+ canvas.drawPath(path, paint)
+ }
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ return stream.toByteArray()
+}
diff --git a/updraft-sdk/src/main/res/drawable-xxxhdpi/icn_exit_2_white.png b/updraft-ui-compose/src/commonMain/composeResources/drawable/icn_exit_2_white.png
similarity index 100%
rename from updraft-sdk/src/main/res/drawable-xxxhdpi/icn_exit_2_white.png
rename to updraft-ui-compose/src/commonMain/composeResources/drawable/icn_exit_2_white.png
diff --git a/updraft-sdk/src/main/res/drawable-xxxhdpi/icn_exit_color.png b/updraft-ui-compose/src/commonMain/composeResources/drawable/icn_exit_color.png
similarity index 100%
rename from updraft-sdk/src/main/res/drawable-xxxhdpi/icn_exit_color.png
rename to updraft-ui-compose/src/commonMain/composeResources/drawable/icn_exit_color.png
diff --git a/updraft-sdk/src/main/res/drawable-xxxhdpi/updraft_logo_white.png b/updraft-ui-compose/src/commonMain/composeResources/drawable/updraft_logo_white.png
similarity index 100%
rename from updraft-sdk/src/main/res/drawable-xxxhdpi/updraft_logo_white.png
rename to updraft-ui-compose/src/commonMain/composeResources/drawable/updraft_logo_white.png
diff --git a/updraft-ui-compose/src/commonMain/composeResources/values-de/plurals.xml b/updraft-ui-compose/src/commonMain/composeResources/values-de/plurals.xml
new file mode 100644
index 0000000..e441f10
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/composeResources/values-de/plurals.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ - vor %d Stunde
+ - vor %d Stunden
+
+
+ - vor %d Tag
+ - vor %d Tagen
+
+
+ - vor %d Woche
+ - vor %d Wochen
+
+
+ - vor %d Monat
+ - vor %d Monaten
+
+
+ - vor %d Jahr
+ - vor %d Jahren
+
+
diff --git a/updraft-ui-compose/src/commonMain/composeResources/values-de/strings.xml b/updraft-ui-compose/src/commonMain/composeResources/values-de/strings.xml
new file mode 100644
index 0000000..53d56c9
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/composeResources/values-de/strings.xml
@@ -0,0 +1,37 @@
+
+
+ Abbrechen
+ Nächste
+ OK
+ Vorherige
+ Wiederholen
+ Feedback senden
+ Rückgängig
+ Schütteln Sie das Smartphone, um ein Feedback zu geben oder einen Fehler zu melden.
+ Feedback geben
+ Die Feedback-Funktion wurde deaktiviert. Sie können nicht länger ein Feedback senden oder einen Fehler melden, indem Sie das Smartphone schütteln.
+ Feedback Deaktiviert
+ Beschreibung
+ Beschreiben Sie das Thema...
+ Zeichne etwas hier...
+ Ihre E-Mail
+ Beim Senden des Feedbacks ist ein Fehler aufgetreten.
+ Feedback kann nicht gesendet werden
+ Senden...
+ Vielen Dank für Ihr Feedback
+ Feedback geben
+ Fehler
+ Entwurf
+ Rückmeldung
+ Art der Rückmeldung
+ Fehler
+ gerade eben
+ Möchten Sie die neue Version herunterladen?
+ Veröffentlicht %1$s. Sie verwenden aktuell Version %2$s.
+ Später
+ Öffnen
+ Veröffentlicht %1$s.
+ Neue Version verfügbar
+ Neue Version (%1$s) verfügbar
+ Sie verwenden aktuell Version %1$s.
+
diff --git a/updraft-ui-compose/src/commonMain/composeResources/values/plurals.xml b/updraft-ui-compose/src/commonMain/composeResources/values/plurals.xml
new file mode 100644
index 0000000..ee2849f
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/composeResources/values/plurals.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ - %d hour ago
+ - %d hours ago
+
+
+ - %d day ago
+ - %d days ago
+
+
+ - %d week ago
+ - %d weeks ago
+
+
+ - %d month ago
+ - %d months ago
+
+
+ - %d year ago
+ - %d years ago
+
+
diff --git a/updraft-ui-compose/src/commonMain/composeResources/values/strings.xml b/updraft-ui-compose/src/commonMain/composeResources/values/strings.xml
new file mode 100644
index 0000000..49a4a76
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/composeResources/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+ Cancel
+ Next
+ OK
+ Previous
+ Redo
+ Send Feedback
+ Undo
+ Shake the device to give a feedback or report a bug.
+ Give Feedback
+ Feedback functionality has been disabled. You can no longer send a feedback or report a bug by shaking the device.
+ Feedback Disabled
+ Description
+ Describe the issue...\nBrevity is the source of wit
+ Draw something here...
+ Your Email
+ An error occurred when sending feedback.
+ Failed to send feedback
+ Sending...
+ Thank you for the feedback
+ Give Feedback
+ Bug
+ Design
+ Feedback
+ Type of Feedback
+ Error
+ just now
+ You will be redirected to download the new version of the app
+ Released %1$s. You\'re currently on version %2$s.
+ Later
+ Open
+ Released %1$s.
+ New version available
+ New version (%1$s) available
+ You\'re currently on version %1$s.
+
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAge.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAge.kt
new file mode 100644
index 0000000..1ac068d
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAge.kt
@@ -0,0 +1,46 @@
+package com.appswithlove.updraft.ui.dialogs
+
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+internal sealed interface RelativeAge {
+ data object JustNow : RelativeAge
+ data class Hours(val count: Int) : RelativeAge
+ data class Days(val count: Int) : RelativeAge
+ data class Weeks(val count: Int) : RelativeAge
+ data class Months(val count: Int) : RelativeAge
+ data class Years(val count: Int) : RelativeAge
+}
+
+@OptIn(ExperimentalTime::class)
+internal fun parseCreateAt(createAt: String): Instant? {
+ val candidates = listOf(
+ createAt,
+ createAt.replace(" ", "T"),
+ createAt.replace(" ", "T") + "Z",
+ createAt + "Z",
+ )
+ for (candidate in candidates) {
+ try {
+ return Instant.parse(candidate)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+ return null
+}
+
+@OptIn(ExperimentalTime::class)
+internal fun relativeAge(createAt: Instant, now: Instant): RelativeAge {
+ val age = now - createAt
+ val days = age.inWholeDays
+ return when {
+ age < 1.hours -> RelativeAge.JustNow
+ age < 1.days -> RelativeAge.Hours(age.inWholeHours.toInt())
+ days < 7 -> RelativeAge.Days(days.toInt())
+ days < 30 -> RelativeAge.Weeks((days / 7).toInt())
+ days < 365 -> RelativeAge.Months((days / 30).toInt())
+ else -> RelativeAge.Years((days / 365).toInt())
+ }
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/UpdraftDialogs.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/UpdraftDialogs.kt
new file mode 100644
index 0000000..e3c47c4
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/dialogs/UpdraftDialogs.kt
@@ -0,0 +1,150 @@
+package com.appswithlove.updraft.ui.dialogs
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.ui.resources.Res
+import com.appswithlove.updraft.ui.resources.updraft_button_cancel
+import com.appswithlove.updraft.ui.resources.updraft_button_ok
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDialog_description
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDialog_title
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDisabled_description
+import com.appswithlove.updraft.ui.resources.updraft_feedbackDisabled_title
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_description
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_laterButton
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_openButton
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_title
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_titleWithVersion
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_yourVersion
+import com.appswithlove.updraft.ui.resources.updraft_relative_daysAgo
+import com.appswithlove.updraft.ui.resources.updraft_relative_hoursAgo
+import com.appswithlove.updraft.ui.resources.updraft_relative_justNow
+import com.appswithlove.updraft.ui.resources.updraft_relative_monthsAgo
+import com.appswithlove.updraft.ui.resources.updraft_relative_weeksAgo
+import com.appswithlove.updraft.ui.resources.updraft_relative_yearsAgo
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_descriptionFull
+import com.appswithlove.updraft.ui.resources.updraft_updateAvailable_releasedRelative
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlinx.coroutines.flow.Flow
+import org.jetbrains.compose.resources.pluralStringResource
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+private fun relativeAgePhrase(age: RelativeAge): String = when (age) {
+ is RelativeAge.JustNow -> stringResource(Res.string.updraft_relative_justNow)
+ is RelativeAge.Hours -> pluralStringResource(Res.plurals.updraft_relative_hoursAgo, age.count, age.count)
+ is RelativeAge.Days -> pluralStringResource(Res.plurals.updraft_relative_daysAgo, age.count, age.count)
+ is RelativeAge.Weeks -> pluralStringResource(Res.plurals.updraft_relative_weeksAgo, age.count, age.count)
+ is RelativeAge.Months -> pluralStringResource(Res.plurals.updraft_relative_monthsAgo, age.count, age.count)
+ is RelativeAge.Years -> pluralStringResource(Res.plurals.updraft_relative_yearsAgo, age.count, age.count)
+}
+
+/**
+ * Shows the new-version message, with `event.createAt` rendered as a human-relative
+ * age ("Released 3 weeks ago.") when it is present and parseable.
+ */
+@OptIn(ExperimentalTime::class)
+@Composable
+fun UpdateAvailableDialog(
+ event: UpdraftEvent.UpdateAvailable,
+ onOpen: (String) -> Unit,
+ onLater: () -> Unit,
+) {
+ val title = if (!event.version.isNullOrBlank()) {
+ stringResource(Res.string.updraft_updateAvailable_titleWithVersion, event.version!!)
+ } else {
+ stringResource(Res.string.updraft_updateAvailable_title)
+ }
+ val agePhrase = event.createAt
+ ?.let { parseCreateAt(it) }
+ ?.let { relativeAgePhrase(relativeAge(it, Clock.System.now())) }
+ val yourVersion = event.yourVersion
+ val message = when {
+ agePhrase != null && !yourVersion.isNullOrBlank() ->
+ stringResource(Res.string.updraft_updateAvailable_descriptionFull, agePhrase, yourVersion)
+ agePhrase != null ->
+ stringResource(Res.string.updraft_updateAvailable_releasedRelative, agePhrase)
+ !yourVersion.isNullOrBlank() ->
+ stringResource(Res.string.updraft_updateAvailable_yourVersion, yourVersion)
+ else ->
+ stringResource(Res.string.updraft_updateAvailable_description)
+ }
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text(title) },
+ text = { Text(message) },
+ confirmButton = {
+ TextButton(onClick = { onOpen(event.url) }) {
+ Text(stringResource(Res.string.updraft_updateAvailable_openButton))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onLater) {
+ Text(stringResource(Res.string.updraft_updateAvailable_laterButton))
+ }
+ },
+ )
+}
+
+@Composable
+fun FeedbackHintDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.updraft_feedbackDialog_title)) },
+ text = { Text(stringResource(Res.string.updraft_feedbackDialog_description)) },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.updraft_button_ok)) }
+ },
+ )
+}
+
+@Composable
+fun FeedbackDisabledDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.updraft_feedbackDisabled_title)) },
+ text = { Text(stringResource(Res.string.updraft_feedbackDisabled_description)) },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.updraft_button_cancel)) }
+ },
+ )
+}
+
+@Composable
+fun UpdraftEventHost(events: Flow, onFeedbackRequested: () -> Unit) {
+ var updateEvent by remember { mutableStateOf(null) }
+ var showHint by remember { mutableStateOf(false) }
+ var showDisabled by remember { mutableStateOf(false) }
+
+ LaunchedEffect(events) {
+ events.collect { event ->
+ when (event) {
+ is UpdraftEvent.UpdateAvailable -> updateEvent = event
+ UpdraftEvent.ShowFeedbackHint -> showHint = true
+ UpdraftEvent.FeedbackDisabled -> showDisabled = true
+ UpdraftEvent.FeedbackRequested -> onFeedbackRequested()
+ UpdraftEvent.CloseFeedback -> Unit
+ is UpdraftEvent.Error -> Unit
+ }
+ }
+ }
+
+ updateEvent?.let { event ->
+ UpdateAvailableDialog(
+ event = event,
+ onOpen = { url -> Updraft.openUpdateUrl(url); updateEvent = null },
+ onLater = { updateEvent = null },
+ )
+ }
+ if (showHint) FeedbackHintDialog(onDismiss = { showHint = false })
+ if (showDisabled) FeedbackDisabledDialog(onDismiss = { showDisabled = false })
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingCanvas.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingCanvas.kt
new file mode 100644
index 0000000..4c652c1
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingCanvas.kt
@@ -0,0 +1,57 @@
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+
+@Composable
+fun DrawingCanvas(controller: DrawingController, modifier: Modifier = Modifier) {
+ Canvas(
+ modifier = modifier.pointerInput(controller) {
+ awaitPointerEventScope {
+ while (true) {
+ val down = awaitPointerEvent().changes.firstOrNull { it.pressed } ?: continue
+ controller.startPath(down.position)
+ down.consume()
+ try {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (!change.pressed) {
+ break
+ }
+ controller.addPoint(change.position)
+ change.consume()
+ }
+ } finally {
+ controller.endPath()
+ }
+ }
+ }
+ },
+ ) {
+ controller.paths.forEach { drawStroke(it.points, it.color, it.strokeWidthPx) }
+ drawStroke(controller.currentStroke, controller.color, controller.strokeWidthPx)
+ }
+}
+
+private fun DrawScope.drawStroke(points: List, color: Color, strokeWidthPx: Float) {
+ if (points.isEmpty()) return
+ if (points.size == 1) {
+ drawCircle(color, radius = strokeWidthPx / 2f, center = points.first())
+ return
+ }
+ val path = Path().apply {
+ moveTo(points.first().x, points.first().y)
+ points.drop(1).forEach { lineTo(it.x, it.y) }
+ }
+ drawPath(path, color, style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round, join = StrokeJoin.Round))
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingController.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingController.kt
new file mode 100644
index 0000000..af2e358
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/drawing/DrawingController.kt
@@ -0,0 +1,61 @@
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+
+class DrawnPath(
+ val points: List,
+ val color: Color,
+ val strokeWidthPx: Float,
+)
+
+class DrawingController(
+ initialColor: Color = Color.Black,
+ initialStrokeWidthPx: Float = 12f,
+) {
+ private val committedPaths = mutableStateListOf()
+ private val redoStack = mutableStateListOf()
+ private val currentPoints = mutableStateListOf()
+
+ val paths: List get() = committedPaths
+ val currentStroke: List get() = currentPoints
+
+ var color by mutableStateOf(initialColor)
+ var strokeWidthPx by mutableStateOf(initialStrokeWidthPx)
+
+ val canUndo: Boolean get() = committedPaths.isNotEmpty()
+ val canRedo: Boolean get() = redoStack.isNotEmpty()
+
+ fun startPath(point: Offset) {
+ currentPoints.clear()
+ currentPoints.add(point)
+ }
+
+ fun addPoint(point: Offset) {
+ currentPoints.add(point)
+ }
+
+ fun endPath() {
+ if (currentPoints.isNotEmpty()) {
+ committedPaths.add(DrawnPath(currentPoints.toList(), color, strokeWidthPx))
+ redoStack.clear()
+ currentPoints.clear()
+ }
+ }
+
+ fun undo() {
+ if (committedPaths.isNotEmpty()) {
+ redoStack.add(committedPaths.removeAt(committedPaths.lastIndex))
+ }
+ }
+
+ fun redo() {
+ if (redoStack.isNotEmpty()) {
+ committedPaths.add(redoStack.removeAt(redoStack.lastIndex))
+ }
+ }
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreen.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreen.kt
new file mode 100644
index 0000000..c42f9ad
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreen.kt
@@ -0,0 +1,348 @@
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.appswithlove.updraft.FeedbackType
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.ui.drawing.DrawingCanvas
+import com.appswithlove.updraft.ui.drawing.DrawingController
+import com.appswithlove.updraft.ui.resources.Res
+import com.appswithlove.updraft.ui.resources.icn_exit_2_white
+import com.appswithlove.updraft.ui.resources.icn_exit_color
+import com.appswithlove.updraft.ui.resources.updraft_button_cancel
+import com.appswithlove.updraft.ui.resources.updraft_button_next
+import com.appswithlove.updraft.ui.resources.updraft_button_previous
+import com.appswithlove.updraft.ui.resources.updraft_button_sendFeedback
+import com.appswithlove.updraft.ui.resources.updraft_feedback_description_label
+import com.appswithlove.updraft.ui.resources.updraft_feedback_drawDescription
+import com.appswithlove.updraft.ui.resources.updraft_feedback_email_placeholder
+import com.appswithlove.updraft.ui.resources.updraft_feedback_send_failure_description
+import com.appswithlove.updraft.ui.resources.updraft_feedback_title
+import com.appswithlove.updraft.ui.resources.updraft_feedback_type_title
+import com.appswithlove.updraft.ui.resources.updraft_logo_white
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+
+internal val CharcoalGrey = Color(0xFF38383C)
+internal val CharcoalGrey70 = Color(0xB338383C)
+internal val UpdraftYellow = Color(0xFFF6B33E)
+internal val UpdraftRed = Color(0xFFEB6764)
+internal val UpdraftBlue = Color(0xFF4A90E2)
+internal val Greyish = Color(0xFFA6A6A6)
+
+@Composable
+fun FeedbackScreen(
+ screenshotPng: ByteArray?,
+ onClose: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val state = remember {
+ FeedbackScreenState(
+ send = { screenshot, type, description, email ->
+ Updraft.sendFeedback(screenshot, type, description, email)
+ },
+ scope = scope,
+ )
+ }
+ val density = LocalDensity.current
+ val drawingController = remember { DrawingController(initialStrokeWidthPx = with(density) { 4.dp.toPx() }) }
+ var annotating by remember { mutableStateOf(screenshotPng != null) }
+ var canvasSize by remember { mutableStateOf(IntSize.Zero) }
+
+ LaunchedEffect(state.result) {
+ if (state.result?.isSuccess == true) onClose()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().background(CharcoalGrey).windowInsetsPadding(WindowInsets.safeDrawing),
+ ) {
+ Box(modifier = Modifier.fillMaxWidth().padding(start = 10.dp, top = 6.dp, end = 6.dp)) {
+ Image(
+ painter = painterResource(Res.drawable.updraft_logo_white),
+ contentDescription = null,
+ modifier = Modifier.align(Alignment.CenterStart).height(28.dp),
+ contentScale = ContentScale.Fit,
+ )
+ Text(
+ text = stringResource(Res.string.updraft_feedback_title),
+ modifier = Modifier.align(Alignment.Center),
+ color = Color.White,
+ )
+ Image(
+ painter = painterResource(Res.drawable.icn_exit_2_white),
+ contentDescription = null,
+ modifier = Modifier.align(Alignment.CenterEnd).size(48.dp).clickable { onClose() }.padding(12.dp),
+ )
+ }
+
+ if (screenshotPng != null && annotating) {
+ val screenshotBitmap = remember(screenshotPng) { decodePng(screenshotPng) }
+ Box(modifier = Modifier.weight(1f).onSizeChanged { canvasSize = it }) {
+ Image(
+ bitmap = screenshotBitmap,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ )
+ DrawingCanvas(drawingController, modifier = Modifier.fillMaxSize())
+ if (drawingController.paths.isEmpty() && drawingController.currentStroke.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize().background(CharcoalGrey70),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.updraft_feedback_drawDescription),
+ fontSize = 28.sp,
+ fontStyle = FontStyle.Italic,
+ color = UpdraftYellow,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ listOf(Color.Black, Color.White, UpdraftYellow, UpdraftRed).forEach { swatch ->
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .size(48.dp)
+ .clickable { drawingController.color = swatch },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (drawingController.color == swatch) {
+ Box(modifier = Modifier.size(44.dp).border(3.dp, UpdraftBlue, CircleShape))
+ }
+ Box(modifier = Modifier.size(38.dp).background(swatch, CircleShape).border(1.dp, Color.White, CircleShape))
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .drawBehind {
+ drawCircle(
+ color = Color.White,
+ style = Stroke(
+ width = 2.dp.toPx(),
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)),
+ ),
+ )
+ }
+ .clickable { drawingController.undo() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.icn_exit_color),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ TextButton(onClick = { annotating = false }) {
+ Text(stringResource(Res.string.updraft_button_next), fontSize = 16.sp, color = UpdraftYellow)
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ .padding(top = 60.dp, bottom = 30.dp),
+ ) {
+ FeedbackTypeDropdown(selected = state.selectedType, onSelect = { state.selectedType = it })
+ TextField(
+ value = state.description,
+ onValueChange = { state.description = it },
+ placeholder = { Text(stringResource(Res.string.updraft_feedback_description_label), color = Greyish) },
+ minLines = 6,
+ singleLine = false,
+ shape = RectangleShape,
+ textStyle = LocalTextStyle.current.copy(fontSize = 16.sp, fontStyle = FontStyle.Italic, color = CharcoalGrey),
+ colors = feedbackTextFieldColors(),
+ modifier = Modifier.padding(top = 20.dp).fillMaxWidth(),
+ )
+ TextField(
+ value = state.email,
+ onValueChange = { state.email = it },
+ placeholder = { Text(stringResource(Res.string.updraft_feedback_email_placeholder), color = Greyish) },
+ singleLine = true,
+ shape = RectangleShape,
+ textStyle = LocalTextStyle.current.copy(fontSize = 16.sp, fontStyle = FontStyle.Italic, color = CharcoalGrey),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ colors = feedbackTextFieldColors(),
+ modifier = Modifier.padding(top = 20.dp).fillMaxWidth(),
+ )
+ state.uploadProgress?.let { progress ->
+ LinearProgressIndicator(
+ progress = { progress.toFloat() },
+ color = UpdraftYellow,
+ modifier = Modifier.padding(top = 20.dp).fillMaxWidth(),
+ )
+ }
+ if (state.result?.isFailure == true) {
+ Text(
+ text = stringResource(Res.string.updraft_feedback_send_failure_description),
+ color = UpdraftRed,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+ Row(
+ modifier = Modifier.padding(top = 30.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TextButton(
+ onClick = { if (screenshotPng != null) annotating = true else onClose() },
+ ) {
+ Text(
+ text = stringResource(
+ if (screenshotPng != null) Res.string.updraft_button_previous else Res.string.updraft_button_cancel,
+ ),
+ fontSize = 16.sp,
+ color = UpdraftYellow,
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Button(
+ enabled = state.canSend,
+ onClick = {
+ val base = screenshotPng ?: ByteArray(0)
+ val annotated = if (screenshotPng != null) {
+ renderAnnotated(base, drawingController.paths, canvasSize)
+ } else {
+ base
+ }
+ state.sendFeedback(annotated)
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = UpdraftYellow,
+ contentColor = Color.White,
+ disabledContainerColor = Color.Gray,
+ ),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 17.dp),
+ modifier = Modifier.heightIn(min = 44.dp),
+ ) {
+ if (state.uploadProgress != null) {
+ CircularProgressIndicator()
+ } else {
+ Text(stringResource(Res.string.updraft_button_sendFeedback), color = Color.White, fontSize = 16.sp)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun feedbackTextFieldColors() = TextFieldDefaults.colors(
+ focusedContainerColor = Color.White,
+ unfocusedContainerColor = Color.White,
+ disabledContainerColor = Color.White,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ focusedTextColor = CharcoalGrey,
+ unfocusedTextColor = CharcoalGrey,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FeedbackTypeDropdown(selected: FeedbackType?, onSelect: (FeedbackType) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ TextField(
+ value = selected?.name ?: "",
+ onValueChange = {},
+ readOnly = true,
+ placeholder = { Text(stringResource(Res.string.updraft_feedback_type_title), color = Greyish) },
+ textStyle = LocalTextStyle.current.copy(fontSize = 16.sp, fontStyle = FontStyle.Italic),
+ trailingIcon = {
+ CompositionLocalProvider(LocalContentColor provides UpdraftYellow) {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+ }
+ },
+ shape = RectangleShape,
+ colors = feedbackTextFieldColors(),
+ modifier = Modifier.menuAnchor().fillMaxWidth(),
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ shape = RectangleShape,
+ containerColor = Color.White,
+ ) {
+ FeedbackType.entries.forEach { type ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = type.name,
+ fontSize = 16.sp,
+ fontStyle = FontStyle.Italic,
+ color = CharcoalGrey,
+ )
+ },
+ onClick = { onSelect(type); expanded = false },
+ )
+ }
+ }
+ }
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenState.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenState.kt
new file mode 100644
index 0000000..369fc5e
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenState.kt
@@ -0,0 +1,46 @@
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.appswithlove.updraft.FeedbackType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.launch
+
+class FeedbackScreenState(
+ private val send: (screenshot: ByteArray, type: FeedbackType, description: String, email: String) -> Flow,
+ private val scope: CoroutineScope,
+) {
+ var selectedType: FeedbackType? by mutableStateOf(null)
+ var description: String by mutableStateOf("")
+ var email: String by mutableStateOf("")
+ var uploadProgress: Double? by mutableStateOf(null)
+ private set
+ var result: Result? by mutableStateOf(null)
+ private set
+
+ val canSend: Boolean get() = selectedType != null && uploadProgress == null
+
+ fun sendFeedback(screenshotPng: ByteArray) {
+ val type = selectedType ?: return
+ uploadProgress = 0.0
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ send(screenshotPng, type, description, email)
+ .catch { t ->
+ uploadProgress = null
+ result = Result.failure(t)
+ }
+ .onCompletion { cause ->
+ if (cause == null && result == null) {
+ uploadProgress = null
+ result = Result.success(Unit)
+ }
+ }
+ .collect { progress -> uploadProgress = progress }
+ }
+ }
+}
diff --git a/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.kt b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.kt
new file mode 100644
index 0000000..83ee259
--- /dev/null
+++ b/updraft-ui-compose/src/commonMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.kt
@@ -0,0 +1,29 @@
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.unit.IntSize
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+import kotlin.math.min
+
+expect fun decodePng(bytes: ByteArray): ImageBitmap
+expect fun renderAnnotated(base: ByteArray, paths: List, canvasSize: IntSize): ByteArray
+
+/**
+ * Maps a point drawn on the displayed (ContentScale.Fit) image back to the coordinate space of
+ * the full-resolution bitmap it was drawn over.
+ */
+fun mapToBitmapSpace(point: Offset, canvasSize: IntSize, bitmapWidth: Int, bitmapHeight: Int): Offset {
+ if (canvasSize.width <= 0 || canvasSize.height <= 0 || bitmapWidth <= 0 || bitmapHeight <= 0) {
+ return point
+ }
+ val scale = min(
+ canvasSize.width.toFloat() / bitmapWidth.toFloat(),
+ canvasSize.height.toFloat() / bitmapHeight.toFloat(),
+ )
+ val scaledWidth = bitmapWidth * scale
+ val scaledHeight = bitmapHeight * scale
+ val offsetX = (canvasSize.width - scaledWidth) / 2f
+ val offsetY = (canvasSize.height - scaledHeight) / 2f
+ return Offset((point.x - offsetX) / scale, (point.y - offsetY) / scale)
+}
diff --git a/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAgeTest.kt b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAgeTest.kt
new file mode 100644
index 0000000..8a6552d
--- /dev/null
+++ b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/dialogs/RelativeAgeTest.kt
@@ -0,0 +1,40 @@
+package com.appswithlove.updraft.ui.dialogs
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+@OptIn(ExperimentalTime::class)
+class RelativeAgeTest {
+
+ private val now = Instant.parse("2026-07-23T12:00:00Z")
+
+ @Test
+ fun buckets_matchLegacyBehaviour() {
+ assertEquals(RelativeAge.JustNow, relativeAge(now - 5.minutes, now))
+ assertEquals(RelativeAge.Hours(5), relativeAge(now - 5.hours, now))
+ assertEquals(RelativeAge.Days(3), relativeAge(now - 3.days, now))
+ assertEquals(RelativeAge.Weeks(1), relativeAge(now - 8.days, now))
+ assertEquals(RelativeAge.Weeks(4), relativeAge(now - 29.days, now))
+ assertEquals(RelativeAge.Months(1), relativeAge(now - 31.days, now))
+ assertEquals(RelativeAge.Months(11), relativeAge(now - 350.days, now))
+ assertEquals(RelativeAge.Years(2), relativeAge(now - 800.days, now))
+ }
+
+ @Test
+ fun parseCreateAt_acceptsServerFormats() {
+ assertNotNull(parseCreateAt("2026-07-22T09:31:25.785624Z"))
+ assertNotNull(parseCreateAt("2026-07-22T09:31:25Z"))
+ assertNotNull(parseCreateAt("2026-07-22T09:31:25+02:00"))
+ assertNotNull(parseCreateAt("2026-07-22T09:31:25"))
+ assertNotNull(parseCreateAt("2026-07-22 09:31:25"))
+ assertNull(parseCreateAt("not a date"))
+ assertNull(parseCreateAt(""))
+ }
+}
diff --git a/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/drawing/DrawingControllerTest.kt b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/drawing/DrawingControllerTest.kt
new file mode 100644
index 0000000..5fac96a
--- /dev/null
+++ b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/drawing/DrawingControllerTest.kt
@@ -0,0 +1,46 @@
+package com.appswithlove.updraft.ui.drawing
+
+import androidx.compose.ui.geometry.Offset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DrawingControllerTest {
+
+ private fun DrawingController.drawStroke(points: List) {
+ startPath(points.first())
+ points.drop(1).forEach { addPoint(it) }
+ endPath()
+ }
+
+ @Test
+ fun stroke_commitsPath() {
+ val c = DrawingController()
+ c.drawStroke(listOf(Offset(0f, 0f), Offset(10f, 10f)))
+ assertEquals(1, c.paths.size)
+ assertEquals(2, c.paths.first().points.size)
+ }
+
+ @Test
+ fun undoRedo_roundTrips() {
+ val c = DrawingController()
+ c.drawStroke(listOf(Offset(0f, 0f), Offset(1f, 1f)))
+ assertTrue(c.canUndo)
+ c.undo()
+ assertEquals(0, c.paths.size)
+ assertTrue(c.canRedo)
+ c.redo()
+ assertEquals(1, c.paths.size)
+ }
+
+ @Test
+ fun newStroke_clearsRedoStack() {
+ val c = DrawingController()
+ c.drawStroke(listOf(Offset(0f, 0f), Offset(1f, 1f)))
+ c.undo()
+ c.drawStroke(listOf(Offset(2f, 2f), Offset(3f, 3f)))
+ assertFalse(c.canRedo)
+ assertEquals(1, c.paths.size)
+ }
+}
diff --git a/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenStateTest.kt b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenStateTest.kt
new file mode 100644
index 0000000..94d2e7a
--- /dev/null
+++ b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/FeedbackScreenStateTest.kt
@@ -0,0 +1,46 @@
+package com.appswithlove.updraft.ui.feedback
+
+import com.appswithlove.updraft.FeedbackType
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class FeedbackScreenStateTest {
+
+ @Test
+ fun canSend_requiresTypeSelection() = runTest {
+ val state = FeedbackScreenState(send = { _, _, _, _ -> flowOf() }, scope = backgroundScope)
+ assertFalse(state.canSend)
+ state.selectedType = FeedbackType.Bug
+ assertTrue(state.canSend)
+ }
+
+ @Test
+ fun sendFeedback_success_setsResult() = runTest {
+ val state = FeedbackScreenState(send = { _, _, _, _ -> flowOf(0.5, 1.0) }, scope = backgroundScope)
+ state.selectedType = FeedbackType.Feedback
+ state.sendFeedback(byteArrayOf(1))
+ advanceUntilIdle()
+ assertNotNull(state.result)
+ assertTrue(state.result!!.isSuccess)
+ }
+
+ @Test
+ fun sendFeedback_failure_setsFailureResult() = runTest {
+ val state = FeedbackScreenState(
+ send = { _, _, _, _ -> flow { throw IllegalStateException("boom") } },
+ scope = backgroundScope,
+ )
+ state.selectedType = FeedbackType.Bug
+ state.sendFeedback(byteArrayOf(1))
+ advanceUntilIdle()
+ assertTrue(state.result!!.isFailure)
+ assertEquals("boom", state.result!!.exceptionOrNull()!!.message)
+ }
+}
diff --git a/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodecTest.kt b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodecTest.kt
new file mode 100644
index 0000000..529be5f
--- /dev/null
+++ b/updraft-ui-compose/src/commonTest/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodecTest.kt
@@ -0,0 +1,62 @@
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.IntSize
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ImageCodecTest {
+
+ @Test
+ fun mapToBitmapSpace_letterboxedLandscape_mapsPointAccountingForVerticalOffset() {
+ // 200x100 bitmap (2:1) fit into a 100x100 square canvas: scale = 0.5, vertical bars top/bottom.
+ val canvasSize = IntSize(100, 100)
+ val bitmapWidth = 200
+ val bitmapHeight = 100
+
+ // Top-left corner of the scaled (letterboxed) image.
+ val topLeft = mapToBitmapSpace(Offset(0f, 25f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(0f, 0f), topLeft)
+
+ // Center of the canvas maps to the center of the bitmap.
+ val center = mapToBitmapSpace(Offset(50f, 50f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(100f, 50f), center)
+
+ // Bottom-right corner of the scaled image.
+ val bottomRight = mapToBitmapSpace(Offset(100f, 75f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(200f, 100f), bottomRight)
+ }
+
+ @Test
+ fun mapToBitmapSpace_pillarboxedPortrait_mapsPointAccountingForHorizontalOffset() {
+ // 100x200 bitmap (1:2) fit into a 100x100 square canvas: scale = 0.5, horizontal bars left/right.
+ val canvasSize = IntSize(100, 100)
+ val bitmapWidth = 100
+ val bitmapHeight = 200
+
+ // Top-left corner of the scaled (pillarboxed) image.
+ val topLeft = mapToBitmapSpace(Offset(25f, 0f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(0f, 0f), topLeft)
+
+ // Center of the canvas maps to the center of the bitmap.
+ val center = mapToBitmapSpace(Offset(50f, 50f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(50f, 100f), center)
+
+ // Bottom-right corner of the scaled image.
+ val bottomRight = mapToBitmapSpace(Offset(75f, 100f), canvasSize, bitmapWidth, bitmapHeight)
+ assertEquals(Offset(100f, 200f), bottomRight)
+ }
+
+ @Test
+ fun mapToBitmapSpace_exactFit_isIdentityMapping() {
+ val canvasSize = IntSize(100, 200)
+ val point = Offset(40f, 60f)
+ assertEquals(point, mapToBitmapSpace(point, canvasSize, bitmapWidth = 100, bitmapHeight = 200))
+ }
+
+ @Test
+ fun mapToBitmapSpace_zeroCanvasSize_returnsPointUnchanged() {
+ val point = Offset(10f, 20f)
+ assertEquals(point, mapToBitmapSpace(point, IntSize.Zero, bitmapWidth = 100, bitmapHeight = 100))
+ }
+}
diff --git a/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.ios.kt b/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.ios.kt
new file mode 100644
index 0000000..d30c623
--- /dev/null
+++ b/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/feedback/ImageCodec.ios.kt
@@ -0,0 +1,42 @@
+package com.appswithlove.updraft.ui.feedback
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import androidx.compose.ui.unit.IntSize
+import com.appswithlove.updraft.ui.drawing.DrawnPath
+import org.jetbrains.skia.Canvas
+import org.jetbrains.skia.EncodedImageFormat
+import org.jetbrains.skia.Image
+import org.jetbrains.skia.Paint
+import org.jetbrains.skia.PaintMode
+import org.jetbrains.skia.PaintStrokeCap
+import org.jetbrains.skia.PaintStrokeJoin
+import org.jetbrains.skia.Path
+import org.jetbrains.skia.Surface
+
+actual fun decodePng(bytes: ByteArray): ImageBitmap =
+ Image.makeFromEncoded(bytes).toComposeImageBitmap()
+
+actual fun renderAnnotated(base: ByteArray, paths: List, canvasSize: IntSize): ByteArray {
+ val baseImage = Image.makeFromEncoded(base)
+ val surface = Surface.makeRasterN32Premul(baseImage.width, baseImage.height)
+ val canvas: Canvas = surface.canvas
+ canvas.drawImage(baseImage, 0f, 0f)
+ paths.forEach { drawn ->
+ val paint = Paint().apply {
+ color = drawn.color.toArgb()
+ mode = PaintMode.STROKE
+ strokeWidth = drawn.strokeWidthPx
+ strokeCap = PaintStrokeCap.ROUND
+ strokeJoin = PaintStrokeJoin.ROUND
+ isAntiAlias = true
+ }
+ val mappedPoints = drawn.points.map { mapToBitmapSpace(it, canvasSize, baseImage.width, baseImage.height) }
+ val path = Path()
+ mappedPoints.firstOrNull()?.let { path.moveTo(it.x, it.y) }
+ mappedPoints.drop(1).forEach { path.lineTo(it.x, it.y) }
+ canvas.drawPath(path, paint)
+ }
+ return surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)!!.bytes
+}
diff --git a/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/ios/UpdraftIos.kt b/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/ios/UpdraftIos.kt
new file mode 100644
index 0000000..9933c77
--- /dev/null
+++ b/updraft-ui-compose/src/iosMain/kotlin/com/appswithlove/updraft/ui/ios/UpdraftIos.kt
@@ -0,0 +1,103 @@
+package com.appswithlove.updraft.ui.ios
+
+import androidx.compose.ui.window.ComposeUIViewController
+import com.appswithlove.updraft.Updraft
+import com.appswithlove.updraft.UpdraftEvent
+import com.appswithlove.updraft.ui.dialogs.FeedbackDisabledDialog
+import com.appswithlove.updraft.ui.dialogs.FeedbackHintDialog
+import com.appswithlove.updraft.ui.dialogs.UpdateAvailableDialog
+import com.appswithlove.updraft.ui.feedback.FeedbackScreen
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import platform.UIKit.UIApplication
+import platform.UIKit.UIColor
+import platform.UIKit.UIModalPresentationFullScreen
+import platform.UIKit.UIModalPresentationOverFullScreen
+import platform.UIKit.UIViewController
+import platform.UIKit.UIWindow
+
+fun UpdraftFeedbackViewController(screenshotPng: ByteArray?, onClose: () -> Unit): UIViewController =
+ ComposeUIViewController {
+ FeedbackScreen(screenshotPng = screenshotPng, onClose = onClose)
+ }
+
+private fun dialogViewController(event: UpdraftEvent, onDismissed: () -> Unit): UIViewController {
+ val viewController = ComposeUIViewController {
+ when (event) {
+ is UpdraftEvent.UpdateAvailable -> UpdateAvailableDialog(
+ event = event,
+ onOpen = { url -> Updraft.openUpdateUrl(url); onDismissed() },
+ onLater = onDismissed,
+ )
+ UpdraftEvent.ShowFeedbackHint -> FeedbackHintDialog(onDismiss = onDismissed)
+ UpdraftEvent.FeedbackDisabled -> FeedbackDisabledDialog(onDismiss = onDismissed)
+ UpdraftEvent.FeedbackRequested, UpdraftEvent.CloseFeedback, is UpdraftEvent.Error -> Unit
+ }
+ }
+ viewController.view.backgroundColor = UIColor.clearColor
+ viewController.modalPresentationStyle = UIModalPresentationOverFullScreen
+ return viewController
+}
+
+private fun keyWindow(): UIWindow? =
+ UIApplication.sharedApplication.windows
+ .filterIsInstance()
+ .firstOrNull { it.isKeyWindow() }
+
+private fun topmostViewController(): UIViewController? {
+ var top = keyWindow()?.rootViewController
+ while (top?.presentedViewController != null) {
+ top = top?.presentedViewController
+ }
+ return top
+}
+
+object UpdraftIos {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var started = false
+
+ fun autoWire() {
+ if (started) return
+ started = true
+ scope.launch {
+ // Wait until the host app calls Updraft.start()
+ while (!runCatching { Updraft.settings }.isSuccess) delay(100)
+ Updraft.events.collect { event ->
+ when (event) {
+ is UpdraftEvent.UpdateAvailable,
+ UpdraftEvent.ShowFeedbackHint,
+ UpdraftEvent.FeedbackDisabled,
+ -> presentDialog(event)
+
+ UpdraftEvent.FeedbackRequested -> presentFeedback()
+
+ UpdraftEvent.CloseFeedback, is UpdraftEvent.Error -> Unit
+ }
+ }
+ }
+ }
+
+ private fun presentDialog(event: UpdraftEvent) {
+ val presenter = topmostViewController() ?: return
+ lateinit var dialogVc: UIViewController
+ dialogVc = dialogViewController(event) {
+ dialogVc.dismissViewControllerAnimated(true, completion = null)
+ }
+ presenter.presentViewController(dialogVc, animated = true, completion = null)
+ }
+
+ private fun presentFeedback() {
+ val presenter = topmostViewController() ?: return
+ val screenshot = Updraft.takePendingScreenshot()
+ lateinit var feedbackVc: UIViewController
+ feedbackVc = UpdraftFeedbackViewController(screenshot) {
+ feedbackVc.dismissViewControllerAnimated(true, completion = null)
+ Updraft.onFeedbackUiClosed()
+ }
+ feedbackVc.modalPresentationStyle = UIModalPresentationFullScreen
+ presenter.presentViewController(feedbackVc, animated = true, completion = null)
+ }
+}