From 6d21c5a9031acdaa86960f7a136ed0f92a8fc938 Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Sun, 23 Aug 2026 23:15:29 -0500 Subject: [PATCH 1/4] feat: add Presently feedback board links Makes the public UserInput board available from Settings on iOS and Android without widening AT Protocol OAuth permissions. --- .../tech/stygian/presently/PresentlyLinks.kt | 6 +++++ .../presently/camera/CameraControls.kt | 23 +++++++++++++++++-- .../stygian/presently/camera/CameraRoute.kt | 15 ++++++++++++ .../stygian/presently/PresentlyLinksTest.kt | 19 +++++++++++++++ apps/ios/Presently/App/PresentlyApp.swift | 7 ++++++ apps/ios/Presently/Camera/CameraScreen.swift | 15 ++++++++++-- .../FlashesStoryRecordTests.swift | 12 ++++++++++ 7 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/PresentlyLinks.kt create mode 100644 apps/android/app/src/test/java/tech/stygian/presently/PresentlyLinksTest.kt diff --git a/apps/android/app/src/main/java/tech/stygian/presently/PresentlyLinks.kt b/apps/android/app/src/main/java/tech/stygian/presently/PresentlyLinks.kt new file mode 100644 index 0000000..cd15a62 --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/PresentlyLinks.kt @@ -0,0 +1,6 @@ +package tech.stygian.presently + +object PresentlyLinks { + const val FeedbackBoard = + "https://userinput.app/s/did:plc:qy5pluw2bsuq2x6albsgkvx3/3msgekt2gyt2p" +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt index f3918fc..f37baec 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -105,7 +106,7 @@ internal fun CameraTopBar( IconButton(onClick = onSettings) { Icon( imageVector = Icons.Default.Settings, - contentDescription = "Camera settings", + contentDescription = "Settings", tint = MaterialTheme.colorScheme.onSurface, ) } @@ -349,6 +350,7 @@ internal fun CameraSettingsSheet( selectedDefaultCamera: DefaultCamera, onPreferenceSelected: (SaveToPhotosPreference) -> Unit, onDefaultCameraSelected: (DefaultCamera) -> Unit, + onOpenFeedbackBoard: () -> Unit, onDismiss: () -> Unit, ) { ModalBottomSheet(onDismissRequest = onDismiss) { @@ -359,7 +361,7 @@ internal fun CameraSettingsSheet( .padding(start = 24.dp, end = 24.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("Camera Settings", style = MaterialTheme.typography.headlineSmall) + Text("Settings", style = MaterialTheme.typography.headlineSmall) Text( "Save Captured Photos", style = MaterialTheme.typography.titleMedium, @@ -416,6 +418,23 @@ internal fun CameraSettingsSheet( } } } + + Text( + "Feedback", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + "Report a bug, request a feature, ask a question, or leave a comment.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( + onClick = onOpenFeedbackBoard, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Open Feedback Board") + } } } } diff --git a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt index 28d2445..e8e5b40 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.graphics.BitmapFactory +import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.Camera @@ -13,6 +14,7 @@ import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTransformGestures @@ -55,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import tech.stygian.presently.data.AppSettings +import tech.stygian.presently.PresentlyLinks import tech.stygian.presently.data.DefaultCamera import tech.stygian.presently.data.LocalStoryDraft import tech.stygian.presently.data.PresentlyDatabase @@ -238,6 +241,18 @@ private fun CameraScreen(auth: OAuthSessionManager) { ) } }, + onOpenFeedbackBoard = { + runCatching { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, Uri.parse(PresentlyLinks.FeedbackBoard)) + }.onFailure { + scope.launch { + snackbar.showSnackbar("Could not open the feedback board.") + } + } + }, onDismiss = { showSettings = false }, ) } diff --git a/apps/android/app/src/test/java/tech/stygian/presently/PresentlyLinksTest.kt b/apps/android/app/src/test/java/tech/stygian/presently/PresentlyLinksTest.kt new file mode 100644 index 0000000..971b9d9 --- /dev/null +++ b/apps/android/app/src/test/java/tech/stygian/presently/PresentlyLinksTest.kt @@ -0,0 +1,19 @@ +package tech.stygian.presently + +import java.net.URI +import org.junit.Assert.assertEquals +import org.junit.Test + +class PresentlyLinksTest { + @Test + fun feedbackBoardUsesTheCanonicalPresentlyBoard() { + val uri = URI(PresentlyLinks.FeedbackBoard) + + assertEquals("https", uri.scheme) + assertEquals("userinput.app", uri.host) + assertEquals( + "/s/did:plc:qy5pluw2bsuq2x6albsgkvx3/3msgekt2gyt2p", + uri.path, + ) + } +} diff --git a/apps/ios/Presently/App/PresentlyApp.swift b/apps/ios/Presently/App/PresentlyApp.swift index 634b69a..d02f919 100644 --- a/apps/ios/Presently/App/PresentlyApp.swift +++ b/apps/ios/Presently/App/PresentlyApp.swift @@ -1,6 +1,13 @@ +import Foundation import SwiftData import SwiftUI +enum PresentlyLinks { + static let feedbackBoard = URL( + string: "https://userinput.app/s/did:plc:qy5pluw2bsuq2x6albsgkvx3/3msgekt2gyt2p" + )! +} + @main struct PresentlyApp: App { private let modelContainer: ModelContainer diff --git a/apps/ios/Presently/Camera/CameraScreen.swift b/apps/ios/Presently/Camera/CameraScreen.swift index 3501afe..0fa4604 100644 --- a/apps/ios/Presently/Camera/CameraScreen.swift +++ b/apps/ios/Presently/Camera/CameraScreen.swift @@ -134,7 +134,7 @@ struct CameraScreen: View { .frame(width: 44, height: 44) .background(.black.opacity(0.45), in: Circle()) } - .accessibilityLabel("Camera settings") + .accessibilityLabel("Settings") } .foregroundStyle(.white) .padding() @@ -537,8 +537,19 @@ private struct CameraSettingsSheet: View { } footer: { Text("The camera switch button still changes cameras for the current session.") } + + Section { + Link(destination: PresentlyLinks.feedbackBoard) { + Label("Open Feedback Board", systemImage: "arrow.up.right.square") + } + .accessibilityHint("Opens the public Presently board on UserInput") + } header: { + Text("Feedback") + } footer: { + Text("Report a bug, request a feature, ask a question, or leave a comment.") + } } - .navigationTitle("Camera Settings") + .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { diff --git a/apps/ios/PresentlyTests/FlashesStoryRecordTests.swift b/apps/ios/PresentlyTests/FlashesStoryRecordTests.swift index 80d1969..c31d361 100644 --- a/apps/ios/PresentlyTests/FlashesStoryRecordTests.swift +++ b/apps/ios/PresentlyTests/FlashesStoryRecordTests.swift @@ -3,6 +3,18 @@ import Foundation import Testing @testable import Presently +struct PresentlyLinksTests { + @Test + func feedbackBoardUsesTheCanonicalPresentlyBoard() { + #expect(PresentlyLinks.feedbackBoard.scheme == "https") + #expect(PresentlyLinks.feedbackBoard.host == "userinput.app") + #expect( + PresentlyLinks.feedbackBoard.path + == "/s/did:plc:qy5pluw2bsuq2x6albsgkvx3/3msgekt2gyt2p" + ) + } +} + struct FlashesStoryRecordTests { @Test func createsCurrentFlashesStoryShape() throws { From 9d8b124c1303312d3ac63421dcc5415b616b109b Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Mon, 14 Sep 2026 14:26:56 -0500 Subject: [PATCH 2/4] docs: define native story editor behavior Record the approved first version and publication snapshot rules for PRSTLY-1 so both native clients share draft and export expectations. --- docs/STORY_EDITOR.md | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/STORY_EDITOR.md diff --git a/docs/STORY_EDITOR.md b/docs/STORY_EDITOR.md new file mode 100644 index 0000000..e5f1fbf --- /dev/null +++ b/docs/STORY_EDITOR.md @@ -0,0 +1,61 @@ +# Native story editor + +Tracked in [PRSTLY-1](https://linear.app/stygian-tech/issue/PRSTLY-1/add-native-text-and-sticker-editing-to-photo-stories). + +## First version + +The photo review screen is an editable composition. A user can add text with +font, color, alignment and background options; add emoji or a static image; +move, resize and rotate layers; align them to the photo's center; delete a +selected layer; and undo edits. + +Use native text input and image selection on each platform. Image input from +keyboards depends on the installed keyboard and operating system. Imported +images become local sticker assets. Subject cutouts, animation, video, filters +and remote sticker catalogs are outside this version. + +## Composition and rendering + +- Keep the original photo and a versioned recipe of editable layers locally. +- Store layer coordinates relative to the upright photo, excluding letterbox + bars and editor controls. Scale text and assets in this same coordinate space. +- Draw layers in stable stacking order. Selection borders and alignment guides + are editor UI and never appear in exported photos. +- Use the same native drawing routines for preview and export. Bound decoded + image sizes and perform expensive image work outside gesture updates. +- Render the composition to JPEG before publishing. The resulting JPEG must + meet the existing 10 MiB limit. No editor metadata is sent to the PDS. + +The existing `blue.flashes.story.post` record and OAuth scopes remain sufficient. +Other clients display the final photo without needing editor support. Text drawn +on a photo is separate from the record's optional text/facets fields. + +## Draft and retry lifecycle + +Persist a draft before posting and save completed edits so the composition can +be restored after the app exits. Existing photo-only drafts must remain usable. +Keep failed drafts and expose retry and discard. + +Before the first network publish attempt, freeze the rendered JPEG, creation +time and record key together. Retries use that exact publication snapshot, and +editing is disabled for a draft that has entered publication. This prevents an +ambiguous response from causing a retry to publish a different composition. +Use the same JPEG for publishing and the optional device-library copy. + +## Verification + +Automated checks should cover: + +- Recipe round trips, invalid/unsupported recipes, stable layer identity. +- Undo for insertion, editing, deletion and complete gestures. +- Portrait and landscape image geometry, scale limits and center snapping. +- Text wrapping, emoji, transparent stickers and orientation-aware rendering. +- Output limits, old draft migration, restored edits and frozen retry data. + +Rendered checks should exercise text entry and style controls, image insertion, +drag/scale/rotation, guides, selection, deletion, undo, and force-quit/relaunch. +Check that toolbars remain reachable with the keyboard and larger text sizes. + +Physical camera capture, device-specific image keyboards, photo-library saving, +and authenticated publishing to a PDS require separate device acceptance. +Simulator builds and local export tests do not establish that acceptance. From 38481cd5c51be7ff8e9eb35f1304bd5e387209ae Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Mon, 14 Sep 2026 14:46:28 -0500 Subject: [PATCH 3/4] feat: add native editable story compositions Let photo drafts retain text, emoji, and static stickers across relaunches. Render native previews and freeze one JPEG and record key for safe publishing retries. --- README.md | 10 +- apps/android/app/build.gradle.kts | 4 + .../5.json | 114 +++++++++ .../presently/editor/StoryEditorSmokeTest.kt | 192 ++++++++++++++ .../presently/camera/CameraControls.kt | 5 +- .../stygian/presently/camera/CameraRoute.kt | 196 +++++++++++---- .../stygian/presently/data/LocalStoryDraft.kt | 3 + .../stygian/presently/data/PresentlyDao.kt | 6 + .../presently/data/PresentlyDatabase.kt | 12 +- .../presently/editor/StoryCanvasView.kt | 143 +++++++++++ .../presently/editor/StoryComposition.kt | 99 ++++++++ .../stygian/presently/editor/StoryEditor.kt | 196 +++++++++++++++ .../presently/editor/StoryEditorStore.kt | 168 +++++++++++++ .../stygian/presently/editor/StoryRenderer.kt | 91 +++++++ .../presently/editor/StoryCompositionTest.kt | 34 +++ .../editor/StoryEditorAndroidTest.kt | 174 +++++++++++++ apps/ios/Presently.xcodeproj/project.pbxproj | 61 +++-- apps/ios/Presently/Camera/CameraScreen.swift | 167 +++++++----- .../Presently/Editor/StoryComposition.swift | 186 ++++++++++++++ .../Presently/Editor/StoryEditorCanvas.swift | 189 ++++++++++++++ .../Presently/Editor/StoryEditorSession.swift | 89 +++++++ .../Presently/Editor/StoryEditorView.swift | 224 +++++++++++++++++ .../ios/Presently/Editor/StoryTextInput.swift | 159 ++++++++++++ .../Presently/Models/LocalStoryDraft.swift | 8 + .../ios/PresentlyTests/StoryEditorTests.swift | 237 ++++++++++++++++++ docs/MVP_PLAN.md | 7 +- 26 files changed, 2652 insertions(+), 122 deletions(-) create mode 100644 apps/android/app/schemas/tech.stygian.presently.data.PresentlyDatabase/5.json create mode 100644 apps/android/app/src/androidTest/java/tech/stygian/presently/editor/StoryEditorSmokeTest.kt create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/editor/StoryCanvasView.kt create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/editor/StoryComposition.kt create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditor.kt create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditorStore.kt create mode 100644 apps/android/app/src/main/java/tech/stygian/presently/editor/StoryRenderer.kt create mode 100644 apps/android/app/src/test/java/tech/stygian/presently/editor/StoryCompositionTest.kt create mode 100644 apps/android/app/src/test/java/tech/stygian/presently/editor/StoryEditorAndroidTest.kt create mode 100644 apps/ios/Presently/Editor/StoryComposition.swift create mode 100644 apps/ios/Presently/Editor/StoryEditorCanvas.swift create mode 100644 apps/ios/Presently/Editor/StoryEditorSession.swift create mode 100644 apps/ios/Presently/Editor/StoryEditorView.swift create mode 100644 apps/ios/Presently/Editor/StoryTextInput.swift create mode 100644 apps/ios/PresentlyTests/StoryEditorTests.swift diff --git a/README.md b/README.md index 8b16d06..c22ff3b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Presently is a camera-first AT Protocol client for sharing a photo as a 24-hour story that Flashes can display. The MVP intentionally has no feed: it -opens to the camera, captures one JPEG, optionally saves it to the device photo -library, and publishes a `blue.flashes.story.post` record. +opens to the camera, captures one photo, lets you add text and static stickers, +optionally saves the finished image to the device photo library, and publishes +a `blue.flashes.story.post` record. ## Monorepo @@ -17,6 +18,7 @@ services/ docs/ MVP_PLAN.md Delivery plan, boundaries, and acceptance criteria STORY_CONTRACT.md Published Flashes record contract used by both clients + STORY_EDITOR.md Native editor, editable drafts, and export behavior ``` ## Current Implementation @@ -24,6 +26,10 @@ docs/ - Both clients implement the camera/review interaction, friendly account typeahead, native OAuth/DPoP session handling, and durable local story drafts. - Both clients model the exact current Flashes story record. +- Native photo review supports styled text, emoji and static image stickers, + layer gestures, center guides, deletion and undo. Editable drafts retain the + original photo and layer recipe; publishing and optional photo-library saving + use the same flattened JPEG. See [the editor design](docs/STORY_EDITOR.md). - The Go OAuth service serves native public-client metadata with the minimum create-only Flashes actor, story, and JPEG blob permissions. - Both apps implement JPEG blob upload and `blue.flashes.story.post` creation. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index f0e54ba..626f1ee 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -72,6 +72,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.16.1") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") testImplementation("org.json:json:20250517") } diff --git a/apps/android/app/schemas/tech.stygian.presently.data.PresentlyDatabase/5.json b/apps/android/app/schemas/tech.stygian.presently.data.PresentlyDatabase/5.json new file mode 100644 index 0000000..245621e --- /dev/null +++ b/apps/android/app/schemas/tech.stygian.presently.data.PresentlyDatabase/5.json @@ -0,0 +1,114 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "c47ea6094aff347002a0da4f2db0ba34", + "entities": [ + { + "tableName": "local_story_drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `imageData` BLOB NOT NULL, `createdAtEpochMillis` INTEGER NOT NULL, `state` TEXT NOT NULL, `lastError` TEXT, `publishedUri` TEXT, `publishedCid` TEXT, `recordKey` TEXT, `originalImagePath` TEXT, `compositionJson` TEXT, `renderedImagePath` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageData", + "columnName": "imageData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "createdAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedUri", + "columnName": "publishedUri", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedCid", + "columnName": "publishedCid", + "affinity": "TEXT" + }, + { + "fieldPath": "recordKey", + "columnName": "recordKey", + "affinity": "TEXT" + }, + { + "fieldPath": "originalImagePath", + "columnName": "originalImagePath", + "affinity": "TEXT" + }, + { + "fieldPath": "compositionJson", + "columnName": "compositionJson", + "affinity": "TEXT" + }, + { + "fieldPath": "renderedImagePath", + "columnName": "renderedImagePath", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `saveToPhotosPreference` TEXT NOT NULL, `defaultCamera` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveToPhotosPreference", + "columnName": "saveToPhotosPreference", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultCamera", + "columnName": "defaultCamera", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c47ea6094aff347002a0da4f2db0ba34')" + ] + } +} \ No newline at end of file diff --git a/apps/android/app/src/androidTest/java/tech/stygian/presently/editor/StoryEditorSmokeTest.kt b/apps/android/app/src/androidTest/java/tech/stygian/presently/editor/StoryEditorSmokeTest.kt new file mode 100644 index 0000000..68a9eb0 --- /dev/null +++ b/apps/android/app/src/androidTest/java/tech/stygian/presently/editor/StoryEditorSmokeTest.kt @@ -0,0 +1,192 @@ +package tech.stygian.presently.editor + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.net.Uri +import android.os.SystemClock +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.FixMethodOrder +import org.junit.runners.MethodSorters +import org.junit.runner.RunWith +import tech.stygian.presently.MainActivity +import tech.stygian.presently.data.PresentlyDatabase +import java.io.ByteArrayOutputStream +import java.io.File + +/** Run edit and restore methods separately, with host force-stop/relaunch between phases. + * Fixtures create their own draft; existing user drafts and account state are preserved. + * This smoke test deliberately never invokes Post or opens account authorization. + */ +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class StoryEditorSmokeTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context = instrumentation.targetContext + private val device = UiDevice.getInstance(instrumentation) + private val store = StoryEditorStore(context, PresentlyDatabase.get(context).presentlyDao()) + private val fixturePreferences = context.getSharedPreferences("editor-smoke-test-fixture", Context.MODE_PRIVATE) + + @Test fun editStoryAndExerciseUndoDelete() { + val source = Bitmap.createBitmap(800, 1200, Bitmap.Config.ARGB_8888) + Canvas(source).apply { + drawColor(0xFF32748C.toInt()) + drawCircle(400f, 700f, 300f, Paint().apply { color = 0xFFD9B277.toInt() }) + } + val jpeg = ByteArrayOutputStream().use { source.compress(Bitmap.CompressFormat.JPEG, 95, it); it.toByteArray() } + source.recycle() + val draft = runBlocking { + store.restore()?.takeIf { it.id == fixturePreferences.getString("draftId", null) }?.let { store.discard(it) } + store.create(jpeg) + } + assertTrue(fixturePreferences.edit().putString("draftId", draft.id).commit()) + launchEditor() + var fixtureUri: Uri? = null + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + var previousClip: ClipData? = null + instrumentation.runOnMainSync { previousClip = clipboard.primaryClip } + try { + button("Text").click() + val input = requireNotNull(device.wait(Until.findObject(By.clazz("android.widget.EditText")), 5000)) + input.text = "Native editor smoke" + button("Done").click() + awaitComposition { it.layers.size == 1 && it.layers[0].text == "Native editor smoke" } + + button("Emoji").click() + button("Done").click() + awaitComposition { it.layers.size == 2 && it.layers[1].text == "✨" } + + val sticker = Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888) + Canvas(sticker).drawCircle(64f, 64f, 56f, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.MAGENTA }) + fixtureUri = requireNotNull(context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "Presently editor smoke sticker.png") + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + })) + context.contentResolver.openOutputStream(fixtureUri)?.use { sticker.compress(Bitmap.CompressFormat.PNG, 100, it) } + sticker.recycle() + instrumentation.runOnMainSync { + clipboard.setPrimaryClip(ClipData.newUri(context.contentResolver, "Smoke sticker", fixtureUri)) + } + button("Paste").click() + awaitComposition { it.layers.size == 3 && it.layers.last().asset != null } + button("Undo").click() + awaitComposition { it.layers.size == 2 } + button("Paste").click() + awaitComposition { it.layers.size == 3 && it.layers.last().asset != null } + + button("Layers").click() + button("2. ✨").click() + button("Delete").click() + awaitComposition { it.layers.size == 2 && it.layers.none { layer -> layer.text == "✨" } } + button("Undo").click() + awaitComposition { it.layers.size == 3 && it.layers.any { layer -> layer.text == "✨" } } + + button("Layers").click() + button("3. Image sticker").click() + button("Rotate").click() + button("Right").click() + button("Down").click() + button("Larger").click() + val edited = awaitComposition { + it.layers.last().rotation == 15f && it.layers.last().x > 0.5f && it.layers.last().y > 0.5f && it.layers.last().scale > 1f + } + assertTrue(fixturePreferences.edit().putString("composition", edited.encode()).commit()) + screenshot("edited") + assertTrue(button("Post photo", description = true).isEnabled) + } catch (error: Throwable) { + screenshot("failure") + throw error + } finally { + instrumentation.runOnMainSync { + previousClip?.let { clipboard.setPrimaryClip(it) } ?: run { + if (android.os.Build.VERSION.SDK_INT >= 28) clipboard.clearPrimaryClip() + else clipboard.setPrimaryClip(ClipData.newPlainText("", "")) + } + } + fixtureUri?.let { context.contentResolver.delete(it, null, null) } + } + } + + @Test fun verifyRestoredStory() { + val expected = requireNotNull(fixturePreferences.getString("composition", null)) { "Run editStoryAndExerciseUndoDelete first." } + launchEditor() + try { + val actual = awaitComposition { it.encode() == expected } + assertEquals(3, actual.layers.size) + button("Layers").click() + assertNotNull(button("1. Native editor smoke")) + assertNotNull(button("2. ✨")) + assertNotNull(button("3. Image sticker")) + button("Close").click() + screenshot("restored") + assertTrue(button("Post photo", description = true).isEnabled) + } catch (error: Throwable) { screenshot("restore-failure"); throw error } + } + + private fun launchEditor() { + device.executeShellCommand("pm grant tech.stygian.presently android.permission.CAMERA") + context.startActivity(Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) + assertTrue(device.wait(Until.hasObject(By.text("Text")), 10_000)) + } + + private fun button(text: String, description: Boolean = false): UiObject2 { + val selector = if (description) By.desc(text) else By.text(text) + refreshAccessibility() + device.wait(Until.findObject(selector), 1000)?.let { return it } + if (!description) { + val scrollables = device.findObjects(By.scrollable(true)).size + for (index in 0 until scrollables) { + runCatching { + UiScrollable(UiSelector().scrollable(true).instance(index)).setAsHorizontalList().setMaxSearchSwipes(4) + .scrollIntoView(UiSelector().text(text)) + } + refreshAccessibility() + device.wait(Until.findObject(selector), 1000)?.let { return it } + } + } + return requireNotNull(device.wait(Until.findObject(selector), 3000)) { "Missing control: $text" } + } + + private fun refreshAccessibility() { + runCatching { device.waitForIdle(1000) } + // Compose's horizontal rows can leave a stale UiAutomator node cache after a scroll. + if (android.os.Build.VERSION.SDK_INT >= 34) instrumentation.uiAutomation.clearCache() + } + + private fun awaitComposition(predicate: (StoryComposition) -> Boolean): StoryComposition { + val deadline = SystemClock.elapsedRealtime() + 10_000 + var last = StoryComposition() + do { + val draft = runBlocking { store.restore() } + if (draft != null && draft.id == fixturePreferences.getString("draftId", null)) { + last = StoryComposition.decode(draft.compositionJson) + if (predicate(last)) return last + } + SystemClock.sleep(100) + } while (SystemClock.elapsedRealtime() < deadline) + error("Expected editor state was not persisted: ${last.encode()}") + } + + private fun screenshot(name: String) { + val directory = File(context.getExternalFilesDir(null), "editor-smoke").apply { mkdirs() } + device.takeScreenshot(File(directory, "$name.png")) + device.dumpWindowHierarchy(File(directory, "$name.xml")) + } +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt index f37baec..bf3f143 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraControls.kt @@ -248,6 +248,7 @@ private fun ShutterButton( internal fun ReviewControls( preference: SaveToPhotosPreference, isPublishing: Boolean, + interactionBlocked: Boolean, saveThisPhoto: Boolean, onSaveThisPhotoChanged: (Boolean) -> Unit, onRetake: () -> Unit, @@ -294,7 +295,7 @@ internal fun ReviewControls( ) { FilledIconButton( onClick = onRetake, - enabled = !isPublishing, + enabled = !isPublishing && !interactionBlocked, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.inverseSurface, contentColor = MaterialTheme.colorScheme.inverseOnSurface, @@ -310,7 +311,7 @@ internal fun ReviewControls( } FilledIconButton( onClick = onPost, - enabled = !isPublishing, + enabled = !isPublishing && !interactionBlocked, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, diff --git a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt index e8e5b40..afcc2a9 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/camera/CameraRoute.kt @@ -3,8 +3,8 @@ package tech.stygian.presently.camera import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.graphics.BitmapFactory import android.net.Uri +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.Camera @@ -15,7 +15,6 @@ import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.browser.customtabs.CustomTabsIntent -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement @@ -45,9 +44,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -56,13 +53,17 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import tech.stygian.presently.editor.StoryComposition +import tech.stygian.presently.editor.StoryLayer +import tech.stygian.presently.editor.StoryEditor +import tech.stygian.presently.editor.StoryEditorStore import tech.stygian.presently.data.AppSettings import tech.stygian.presently.PresentlyLinks import tech.stygian.presently.data.DefaultCamera import tech.stygian.presently.data.LocalStoryDraft import tech.stygian.presently.data.PresentlyDatabase -import tech.stygian.presently.story.FlashesStoryContract -import tech.stygian.presently.story.ATProtoTid import tech.stygian.presently.oauth.AccountSheet import tech.stygian.presently.oauth.OAuthSessionManager import java.io.File @@ -117,6 +118,7 @@ fun CameraRoute(auth: OAuthSessionManager) { private fun CameraScreen(auth: OAuthSessionManager) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentActivity = LocalActivity.current val database = remember { PresentlyDatabase.get(context) } val dao = remember { database.presentlyDao() } val settingsFlow = remember(dao) { @@ -148,6 +150,67 @@ private fun CameraScreen(auth: OAuthSessionManager) { var canSwitchCamera by remember { mutableStateOf(false) } var isPublishing by remember { mutableStateOf(false) } var activeDraft by remember { mutableStateOf(null) } + val editorStore = remember { StoryEditorStore(context.applicationContext, dao) } + val editorWrites = remember { Mutex() } + var composition by remember { mutableStateOf(StoryComposition()) } + var stickerAssets by remember { mutableStateOf>(emptyMap()) } + var editorReady by remember { mutableStateOf(false) } + var editorTransforming by remember { mutableStateOf(false) } + var editorBusy by remember { mutableStateOf(false) } + var pendingEditorWrites by remember { mutableIntStateOf(0) } + + fun reportEditorError(error: Throwable) { + scope.launch { snackbar.showSnackbar(error.localizedMessage ?: "The story could not be edited.") } + } + + fun updateComposition(next: StoryComposition) { + composition = next + pendingEditorWrites += 1 + scope.launch { + try { + editorWrites.withLock { + activeDraft?.let { + val needed = next.layers.mapNotNull { layer -> layer.asset }.toSet() + if (!stickerAssets.keys.containsAll(needed)) stickerAssets = editorStore.loadAssets(it, next) + activeDraft = editorStore.save(it, next) + } + } + } catch (error: Exception) { reportEditorError(error) } + finally { pendingEditorWrites -= 1 } + } + } + + LaunchedEffect(Unit) { + try { + var restored = editorStore.restore() + if (restored == null && + context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0 && + currentActivity?.intent?.getBooleanExtra("presently.editor.sample", false) == true + ) { + val sample = android.graphics.Bitmap.createBitmap(1200, 1600, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(sample) + canvas.drawPaint(android.graphics.Paint().apply { + shader = android.graphics.LinearGradient(0f, 0f, 1200f, 1600f, 0xFF245D77.toInt(), 0xFFE7B784.toInt(), android.graphics.Shader.TileMode.CLAMP) + }) + val data = java.io.ByteArrayOutputStream().use { output -> sample.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, output); output.toByteArray() } + sample.recycle() + restored = editorStore.create(data) + currentActivity.intent.removeExtra("presently.editor.sample") + } + if (restored != null) { + activeDraft = restored + composition = StoryComposition.decode(restored.compositionJson) + capturedJpeg = editorStore.source(restored) + stickerAssets = editorStore.loadAssets(restored, composition) + } + } catch (error: Exception) { reportEditorError(error) } + finally { editorReady = true } + } + DisposableEffect(stickerAssets) { + val old = stickerAssets + onDispose { old.values.forEach { it.recycle() } } + } + fun setZoomRatio(requestedZoomRatio: Float) { val clampedZoomRatio = requestedZoomRatio.coerceIn(minimumZoomRatio, maximumZoomRatio) @@ -281,12 +344,39 @@ private fun CameraScreen(auth: OAuthSessionManager) { }, ) } else { - val bitmap = remember(jpeg) { BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size) } - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Captured story preview", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), + val draft = activeDraft + if (draft != null) StoryEditor( + jpeg = jpeg, + draftId = draft.id, + composition = composition, + assets = stickerAssets, + frozen = draft.renderedImagePath != null || draft.recordKey != null, + editable = !isPublishing && !editorBusy && draft.renderedImagePath == null && draft.recordKey == null, + onChange = ::updateComposition, + onTransforming = { editorTransforming = it }, + onImport = { uri, finished -> + if (composition.layers.size >= StoryComposition.MaximumLayers || composition.layers.count { it.asset != null } >= 10) { + finished() + scope.launch { snackbar.showSnackbar("A story can have up to 30 layers, including 10 image stickers.") } + } else { + editorBusy = true + scope.launch { + try { + editorWrites.withLock { + val currentDraft = checkNotNull(activeDraft) + val asset = editorStore.importSticker(currentDraft, uri) + val next = composition.copy(layers = composition.layers + StoryLayer(asset = asset)) + val loaded = editorStore.loadAssets(currentDraft, next) + activeDraft = editorStore.save(currentDraft, next) + stickerAssets = loaded + composition = next + } + } catch (error: Exception) { reportEditorError(error) } + finally { editorBusy = false; finished() } + } + } + }, + onError = { message -> scope.launch { snackbar.showSnackbar(message) } }, ) } @@ -309,13 +399,26 @@ private fun CameraScreen(auth: OAuthSessionManager) { maximumZoomRatio = maximumZoomRatio, canSwitchCamera = canSwitchCamera, onZoomSelected = ::setZoomRatio, - onCapture = { + onCapture = capture@{ + if (!editorReady || editorBusy) return@capture + editorBusy = true saveThisPhoto = false capturePhoto( imageCapture = imageCapture, context = context, - onCaptured = { capturedJpeg = it }, + onCaptured = { bytes -> + scope.launch { + try { + activeDraft = editorStore.create(bytes) + composition = StoryComposition() + stickerAssets = emptyMap() + capturedJpeg = bytes + } catch (error: Exception) { reportEditorError(error) } + finally { editorBusy = false } + } + }, onError = { message -> + editorBusy = false scope.launch { snackbar.showSnackbar(message) } }, ) @@ -333,39 +436,45 @@ private fun CameraScreen(auth: OAuthSessionManager) { ReviewControls( preference = settings.preference, isPublishing = isPublishing, + interactionBlocked = editorBusy || editorTransforming || pendingEditorWrites > 0, saveThisPhoto = saveThisPhoto, onSaveThisPhotoChanged = { saveThisPhoto = it }, onRetake = { saveThisPhoto = false - activeDraft = null - capturedJpeg = null + editorBusy = true + scope.launch { + try { + editorWrites.withLock { activeDraft?.let { editorStore.discard(it) } } + activeDraft = null + capturedJpeg = null + composition = StoryComposition() + stickerAssets = emptyMap() + } catch (error: Exception) { reportEditorError(error) } + finally { editorBusy = false } + } }, - onPost = { + onPost = post@{ + if (isPublishing || editorBusy || editorTransforming || pendingEditorWrites > 0) return@post + val shouldSavePhoto = settings.preference.shouldSave(saveThisPhoto) scope.launch { - if (jpeg.size > FlashesStoryContract.MaximumImageBytes) { - snackbar.showSnackbar("This image exceeds Flashes' 10 MiB limit.") - return@launch - } if (authState.session?.canPublishStory != true) { showAccount = true return@launch } - val draft = activeDraft?.copy( - state = LocalStoryDraft.State.Publishing.storageValue, - lastError = null, - ) ?: LocalStoryDraft( - imageData = jpeg, - state = LocalStoryDraft.State.Publishing.storageValue, - recordKey = ATProtoTid.create(), - ).also { activeDraft = it } - dao.upsertDraft(draft) - activeDraft = draft - val recordKey = checkNotNull(draft.recordKey) isPublishing = true + var attemptDraft: LocalStoryDraft? = null try { + val (frozen, export) = editorWrites.withLock { + editorStore.freeze(checkNotNull(activeDraft), composition) + } + val draft = frozen.copy(state = LocalStoryDraft.State.Publishing.storageValue, lastError = null) + dao.upsertDraft(draft) + activeDraft = draft + attemptDraft = draft + val recordKey = checkNotNull(draft.recordKey) val published = auth.publishStory( - jpegData = jpeg, + jpegData = export, createdAt = Instant.ofEpochMilli( draft.createdAtEpochMillis, ), @@ -380,11 +489,10 @@ private fun CameraScreen(auth: OAuthSessionManager) { ), ) - val shouldSave = - settings.preference.shouldSave(saveThisPhoto) + val shouldSave = shouldSavePhoto val message = if (shouldSave) { runCatching { - PhotoLibrarySaver.save(context, jpeg) + PhotoLibrarySaver.save(context, export) }.fold( onSuccess = { "Your Story Is Live and Saved to Photos." @@ -399,16 +507,20 @@ private fun CameraScreen(auth: OAuthSessionManager) { saveThisPhoto = false activeDraft = null capturedJpeg = null + composition = StoryComposition() + stickerAssets = emptyMap() snackbar.showSnackbar(message) } catch (error: Throwable) { val detail = error.localizedMessage ?: "The story could not be posted." - val failed = draft.copy( - state = LocalStoryDraft.State.Failed.storageValue, - lastError = detail, - ) - dao.upsertDraft(failed) - activeDraft = failed + attemptDraft?.let { draft -> + val failed = draft.copy( + state = LocalStoryDraft.State.Failed.storageValue, + lastError = detail, + ) + dao.upsertDraft(failed) + activeDraft = failed + } snackbar.showSnackbar( "Your Story Couldn’t Be Posted. Tap the Checkmark to Try Again.", ) diff --git a/apps/android/app/src/main/java/tech/stygian/presently/data/LocalStoryDraft.kt b/apps/android/app/src/main/java/tech/stygian/presently/data/LocalStoryDraft.kt index 0b5fb44..26c2672 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/data/LocalStoryDraft.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/data/LocalStoryDraft.kt @@ -14,6 +14,9 @@ data class LocalStoryDraft( val publishedUri: String? = null, val publishedCid: String? = null, val recordKey: String? = null, + val originalImagePath: String? = null, + val compositionJson: String? = null, + val renderedImagePath: String? = null, ) { enum class State(val storageValue: String) { Pending("pending"), diff --git a/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDao.kt b/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDao.kt index 60c5998..3467ce2 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDao.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDao.kt @@ -15,6 +15,12 @@ interface PresentlyDao { @Upsert suspend fun upsertDraft(draft: LocalStoryDraft) + @Query("SELECT * FROM local_story_drafts WHERE state != 'published' ORDER BY createdAtEpochMillis DESC LIMIT 1") + suspend fun latestUnfinishedDraft(): LocalStoryDraft? + + @Query("DELETE FROM local_story_drafts WHERE id = :id") + suspend fun deleteDraft(id: String) + @Upsert suspend fun upsertSettings(settings: AppSettings) diff --git a/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDatabase.kt b/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDatabase.kt index cc32c25..f2ac078 100644 --- a/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDatabase.kt +++ b/apps/android/app/src/main/java/tech/stygian/presently/data/PresentlyDatabase.kt @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [LocalStoryDraft::class, AppSettings::class], - version = 4, + version = 5, exportSchema = true, ) abstract class PresentlyDatabase : RoomDatabase() { @@ -26,11 +26,19 @@ abstract class PresentlyDatabase : RoomDatabase() { PresentlyDatabase::class.java, "presently.db", ) - .addMigrations(Migration1To2, Migration2To3, Migration3To4) + .addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5) .build() .also { instance = it } } + internal val Migration4To5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE local_story_drafts ADD COLUMN originalImagePath TEXT") + database.execSQL("ALTER TABLE local_story_drafts ADD COLUMN compositionJson TEXT") + database.execSQL("ALTER TABLE local_story_drafts ADD COLUMN renderedImagePath TEXT") + } + } + private val Migration1To2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( diff --git a/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryCanvasView.kt b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryCanvasView.kt new file mode 100644 index 0000000..204174a --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryCanvasView.kt @@ -0,0 +1,143 @@ +package tech.stygian.presently.editor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot + +internal class StoryCanvasView(context: Context) : View(context) { + var source: Bitmap? = null + var assets: Map = emptyMap() + var composition = StoryComposition() + var selectedId: String? = null + var editable = true + var onChange: (StoryComposition) -> Unit = {} + var onSelect: (String?) -> Unit = {} + var onTransforming: (Boolean) -> Unit = {} + var onEdit: (StoryLayer) -> Unit = {} + var transforming = false + private set + private var previousX = 0f + private var previousY = 0f + private var previousSpan = 0f + private var previousAngle = 0f + private var pointerCount = 0 + private var startComposition = composition + private val photoPaint = Paint(Paint.FILTER_BITMAP_FLAG) + private val photoBounds = RectF() + private val guidePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.YELLOW; strokeWidth = 2f; style = Paint.Style.STROKE } + private val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent) = true + override fun onDoubleTap(event: MotionEvent): Boolean { + if (editable) hit(event.x, event.y)?.takeIf { it.asset == null }?.let(onEdit) + return true + } + }) + + init { contentDescription = "Story editor. Select a layer, then drag, pinch to resize, or rotate with two fingers." } + + private fun frame(): ImageFrame? = source?.let { ImageFrame.fit(it.width, it.height, width, height) } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val bitmap = source ?: return + val f = frame() ?: return + canvas.save() + canvas.translate(f.left, f.top) + canvas.scale(f.width / bitmap.width, f.height / bitmap.height) + val imageWidth = bitmap.width.toFloat() + val imageHeight = bitmap.height.toFloat() + canvas.clipRect(0f, 0f, imageWidth, imageHeight) + photoBounds.set(0f, 0f, imageWidth, imageHeight) + canvas.drawBitmap(bitmap, null, photoBounds, photoPaint) + StoryRenderer.drawLayers(canvas, composition, imageWidth, imageHeight, assets) + composition.layers.find { it.id == selectedId }?.takeIf { editable }?.let { layer -> + if (transforming) { + if (abs(layer.x - 0.5f) < 0.02f) canvas.drawLine(imageWidth / 2, 0f, imageWidth / 2, imageHeight, guidePaint) + if (abs(layer.y - 0.5f) < 0.02f) canvas.drawLine(0f, imageHeight / 2, imageWidth, imageHeight / 2, guidePaint) + } + canvas.save() + canvas.translate(layer.x * imageWidth, layer.y * imageHeight) + canvas.rotate(layer.rotation) + canvas.scale(layer.scale, layer.scale) + canvas.drawRect(StoryRenderer.bounds(layer, imageWidth, assets), guidePaint) + canvas.restore() + } + canvas.restore() + } + + private fun hit(x: Float, y: Float): StoryLayer? { + val f = frame() ?: return null + if (x < f.left || x > f.left + f.width || y < f.top || y > f.top + f.height) return null + return composition.layers.asReversed().firstOrNull { layer -> + val bitmap = source ?: return@firstOrNull false + val point = layerLocalPoint(layer, (x - f.left) * bitmap.width / f.width, (y - f.top) * bitmap.height / f.height, bitmap.width.toFloat(), bitmap.height.toFloat()) + StoryRenderer.bounds(layer, bitmap.width.toFloat(), assets).contains(point.first, point.second) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!editable) return false + detector.onTouchEvent(event) + val f = frame() ?: return false + val count = event.pointerCount + val x = if (count > 1) (event.getX(0) + event.getX(1)) / 2 else event.x + val y = if (count > 1) (event.getY(0) + event.getY(1)) / 2 else event.y + val span = if (count > 1) hypot(event.getX(1) - event.getX(0), event.getY(1) - event.getY(0)) else 0f + val angle = if (count > 1) Math.toDegrees(atan2((event.getY(1) - event.getY(0)).toDouble(), (event.getX(1) - event.getX(0)).toDouble())).toFloat() else 0f + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + transforming = true + onTransforming(true) + startComposition = composition + selectedId = hit(event.x, event.y)?.id + onSelect(selectedId) + parent?.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_MOVE -> if (pointerCount == count && selectedId != null) { + composition = composition.copy(layers = composition.layers.map { layer -> + if (layer.id != selectedId) layer else layer.transformed( + (x - previousX) / f.width, (y - previousY) / f.height, + if (count > 1 && previousSpan > 0f) span / previousSpan else 1f, + if (count > 1) angle - previousAngle else 0f, + ) + }) + } + MotionEvent.ACTION_UP -> { + composition = composition.copy(layers = composition.layers.map { layer -> + if (layer.id != selectedId) layer else layer.copy( + x = if (abs(layer.x - 0.5f) < 0.02f) 0.5f else layer.x, + y = if (abs(layer.y - 0.5f) < 0.02f) 0.5f else layer.y, + ) + }) + transforming = false + if (composition != startComposition) onChange(composition) + onTransforming(false) + performClick() + parent?.requestDisallowInterceptTouchEvent(false) + } + MotionEvent.ACTION_CANCEL -> { + composition = startComposition + transforming = false + onTransforming(false) + parent?.requestDisallowInterceptTouchEvent(false) + } + } + previousX = x; previousY = y; previousSpan = span; previousAngle = angle + pointerCount = if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) count - 1 else count + // Rebase after fingers enter/leave; next move establishes its centroid without a jump. + if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) pointerCount = -1 + invalidate() + return true + } + + override fun performClick(): Boolean { super.performClick(); return true } +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryComposition.kt b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryComposition.kt new file mode 100644 index 0000000..f579a36 --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryComposition.kt @@ -0,0 +1,99 @@ +package tech.stygian.presently.editor + +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID +import kotlin.math.cos +import kotlin.math.sin + +/** Coordinates and text sizes are fractions of the orientation-normalized image width/height. */ +internal data class StoryLayer( + val id: String = UUID.randomUUID().toString(), + val text: String = "", + val asset: String? = null, + val x: Float = 0.5f, + val y: Float = 0.5f, + val scale: Float = 1f, + val rotation: Float = 0f, + val color: Int = -1, + val style: String = "bold", + val alignment: String = "center", + val background: Boolean = false, +) { + fun transformed(dx: Float, dy: Float, zoom: Float, degrees: Float): StoryLayer = copy( + x = (x + dx).coerceIn(0f, 1f), + y = (y + dy).coerceIn(0f, 1f), + scale = (scale * zoom).coerceIn(0.2f, 5f), + rotation = ((rotation + degrees) % 360f + 360f) % 360f, + ) +} + +internal data class StoryComposition(val layers: List = emptyList()) { + fun encode(): String = JSONObject().apply { + put("version", 1) + put("layers", JSONArray().apply { + layers.forEach { layer -> put(JSONObject().apply { + put("id", layer.id); put("text", layer.text) + layer.asset?.let { put("asset", it) } + put("x", layer.x); put("y", layer.y); put("scale", layer.scale) + put("rotation", layer.rotation); put("color", layer.color) + put("style", layer.style); put("alignment", layer.alignment) + put("background", layer.background) + }) } + }) + }.toString() + + companion object { + const val MaximumLayers = 30 + const val MaximumTextLength = 500 + + fun decode(json: String?): StoryComposition { + if (json == null) return StoryComposition() + val root = JSONObject(json) + require(root.getInt("version") == 1) { "This draft needs a newer version of Presently." } + val items = root.getJSONArray("layers") + require(items.length() <= MaximumLayers) { "The story has too many layers." } + return StoryComposition((0 until items.length()).map { index -> + val item = items.getJSONObject(index) + fun finite(name: String, default: Double): Float = item.optDouble(name, default) + .toFloat().also { require(it.isFinite()) { "Invalid layer position." } } + val asset = if (item.has("asset")) item.getString("asset") else null + require(asset == null || asset.matches(Regex("[a-zA-Z0-9-]+\\.png"))) { + "Invalid sticker asset." + } + StoryLayer( + id = item.getString("id"), + text = item.optString("text").take(MaximumTextLength), asset = asset, + x = finite("x", 0.5).coerceIn(0f, 1f), + y = finite("y", 0.5).coerceIn(0f, 1f), + scale = finite("scale", 1.0).coerceIn(0.2f, 5f), + rotation = finite("rotation", 0.0), color = item.optInt("color", -1), + style = item.optString("style", "bold").takeIf { it in listOf("bold", "classic", "mono") } ?: "bold", + alignment = item.optString("alignment", "center").takeIf { it in listOf("left", "center", "right") } ?: "center", + background = item.optBoolean("background"), + ) + }) + } + } +} + +internal data class ImageFrame(val left: Float, val top: Float, val width: Float, val height: Float) { + companion object { + fun fit(imageWidth: Int, imageHeight: Int, viewWidth: Int, viewHeight: Int): ImageFrame { + val factor = minOf(viewWidth.toFloat() / imageWidth, viewHeight.toFloat() / imageHeight) + val width = imageWidth * factor + val height = imageHeight * factor + return ImageFrame((viewWidth - width) / 2, (viewHeight - height) / 2, width, height) + } + } +} + +internal fun layerLocalPoint(layer: StoryLayer, x: Float, y: Float, width: Float, height: Float): Pair { + val radians = Math.toRadians(-layer.rotation.toDouble()) + val dx = x - layer.x * width + val dy = y - layer.y * height + return Pair( + (dx * cos(radians) - dy * sin(radians)).toFloat() / layer.scale, + (dx * sin(radians) + dy * cos(radians)).toFloat() / layer.scale, + ) +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditor.kt b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditor.kt new file mode 100644 index 0000000..eeb1cd6 --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditor.kt @@ -0,0 +1,196 @@ +package tech.stygian.presently.editor + +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.widget.EditText +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.inputmethod.InputConnectionCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun StoryEditor( + jpeg: ByteArray, + draftId: String, + composition: StoryComposition, + assets: Map, + editable: Boolean, + frozen: Boolean, + onChange: (StoryComposition) -> Unit, + onImport: (Uri, () -> Unit) -> Unit, + onError: (String) -> Unit, + onTransforming: (Boolean) -> Unit, +) { + val context = LocalContext.current + var bitmap by remember(jpeg) { mutableStateOf(null) } + LaunchedEffect(jpeg) { + runCatching { withContext(Dispatchers.Default) { StoryEditorStore.decodeBounded(jpeg, 2048) } } + .onSuccess { bitmap = it }.onFailure { onError(it.localizedMessage ?: "This photo could not be opened.") } + } + DisposableEffect(bitmap) { val old = bitmap; onDispose { old?.recycle() } } + var selectedId by remember(draftId) { mutableStateOf(null) } + var showLayers by remember(draftId) { mutableStateOf(false) } + var editing by remember(draftId) { mutableStateOf(null) } + val undo = remember(draftId) { mutableStateListOf() } + var lastComposition by remember(draftId) { mutableStateOf(composition) } + LaunchedEffect(composition) { + val added = composition.layers.filter { layer -> lastComposition.layers.none { it.id == layer.id } } + if (added.isNotEmpty() && composition != lastComposition) { + undo.add(lastComposition) + if (undo.size > 30) undo.removeAt(0) + selectedId = added.last().id + } + lastComposition = composition + } + val selected = composition.layers.find { it.id == selectedId } + fun change(next: StoryComposition) { + if (next == composition || !editable) return + undo.add(composition) + if (undo.size > 30) undo.removeAt(0) + lastComposition = next + onChange(next) + } + fun add(layer: StoryLayer) { + if (composition.layers.size >= StoryComposition.MaximumLayers) { onError("A story can have up to 30 layers."); return } + change(composition.copy(layers = composition.layers + layer)); selectedId = layer.id + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> uri?.let { onImport(it) {} } } + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { StoryCanvasView(it) }, + update = { view -> + view.source = bitmap; view.assets = assets; view.editable = editable + if (!view.transforming) { view.composition = composition; view.selectedId = selectedId } + view.onTransforming = onTransforming + view.onSelect = { selectedId = it } + view.onChange = { change(it) } + view.onEdit = { editing = it } + view.invalidate() + }, + modifier = Modifier.fillMaxSize(), + ) + Column(Modifier.align(Alignment.TopCenter).windowInsetsPadding(WindowInsets.safeDrawing).padding(top = 64.dp)) { + Surface(color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), shape = MaterialTheme.shapes.large) { + Column { + Row(Modifier.horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + TextButton(enabled = editable, onClick = { editing = StoryLayer() }) { Text("Text") } + TextButton(enabled = editable, onClick = { editing = StoryLayer(text = "✨", scale = 2.5f) }) { Text("Emoji") } + TextButton(enabled = editable, onClick = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }) { Text("Sticker") } + TextButton(enabled = editable, onClick = { + val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).primaryClip + val uri = if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null + if (uri != null) onImport(uri) {} else onError("Copy an image to paste it as a sticker.") + }) { Text("Paste") } + TextButton(enabled = editable && composition.layers.isNotEmpty(), onClick = { showLayers = true }) { Text("Layers") } + TextButton(enabled = editable && undo.isNotEmpty(), onClick = { val previous = undo.removeAt(undo.lastIndex); lastComposition = previous; onChange(previous); selectedId = null }) { Text("Undo") } + } + if (selected != null && editable) Row(Modifier.horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + if (selected.asset == null) TextButton(onClick = { editing = selected }) { Text("Edit") } + TextButton(onClick = { change(composition.copy(layers = composition.layers.map { if (it.id == selected.id) it.copy(x = 0.5f, y = 0.5f) else it })) }) { Text("Center") } + TextButton(onClick = { change(composition.copy(layers = composition.layers.map { if (it.id == selected.id) it.transformed(0f, 0f, 0.85f, 0f) else it })) }) { Text("Smaller") } + TextButton(onClick = { change(composition.copy(layers = composition.layers.map { if (it.id == selected.id) it.transformed(0f, 0f, 1.15f, 0f) else it })) }) { Text("Larger") } + TextButton(onClick = { change(composition.copy(layers = composition.layers.map { if (it.id == selected.id) it.transformed(0f, 0f, 1f, 15f) else it })) }) { Text("Rotate") } + listOf("Left" to (-0.03f to 0f), "Right" to (0.03f to 0f), "Up" to (0f to -0.03f), "Down" to (0f to 0.03f)).forEach { (label, offset) -> + TextButton(onClick = { change(composition.copy(layers = composition.layers.map { if (it.id == selected.id) it.transformed(offset.first, offset.second, 1f, 0f) else it })) }) { Text(label) } + } + TextButton(onClick = { change(composition.copy(layers = composition.layers.filter { it.id != selected.id })); selectedId = null }) { Text("Delete") } + } + if (frozen) Text("This story is ready to retry. Its edits are saved.", modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.bodySmall) + } + } + } + } + if (showLayers) AlertDialog( + onDismissRequest = { showLayers = false }, title = { Text("Choose a layer") }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + composition.layers.forEachIndexed { index, layer -> TextButton(onClick = { selectedId = layer.id; showLayers = false }) { + Text(if (layer.asset == null) "${index + 1}. ${layer.text.take(40)}" else "${index + 1}. Image sticker") + } } + } }, confirmButton = { TextButton(onClick = { showLayers = false }) { Text("Close") } }, + ) + editing?.let { layer -> + TextLayerDialog(layer, onDismiss = { editing = null }, onSticker = { uri, finished -> onImport(uri, finished); editing = null }, onDone = { updated -> + if (composition.layers.any { it.id == updated.id }) change(composition.copy(layers = composition.layers.map { if (it.id == updated.id) updated else it })) + else add(updated) + editing = null + }) + } +} + +@Composable +private fun TextLayerDialog(initial: StoryLayer, onDismiss: () -> Unit, onSticker: (Uri, () -> Unit) -> Unit, onDone: (StoryLayer) -> Unit) { + var style by remember { mutableStateOf(initial.style) } + var alignment by remember { mutableStateOf(initial.alignment) } + var background by remember { mutableStateOf(initial.background) } + var color by remember { mutableIntStateOf(initial.color) } + var input by remember { mutableStateOf(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit text") }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + AndroidView(factory = { context -> StickerEditText(context).apply { + setText(initial.text); hint = "Type text or use emoji"; minLines = 2; maxLines = 5 + filters = arrayOf(android.text.InputFilter.LengthFilter(StoryComposition.MaximumTextLength)) + onStickerReceived = onSticker; input = this + setSelection(text.length) + } }, modifier = Modifier.fillMaxWidth()) + Row(Modifier.horizontalScroll(rememberScrollState())) { + listOf("bold", "classic", "mono").forEach { value -> FilterChip(selected = style == value, onClick = { style = value }, label = { Text(value.replaceFirstChar { it.uppercase() }) }) } + } + Row(Modifier.horizontalScroll(rememberScrollState())) { + listOf("left", "center", "right").forEach { value -> FilterChip(selected = alignment == value, onClick = { alignment = value }, label = { Text(value.replaceFirstChar { it.uppercase() }) }) } + } + Row(Modifier.horizontalScroll(rememberScrollState())) { + listOf("White" to android.graphics.Color.WHITE, "Black" to android.graphics.Color.BLACK, "Yellow" to android.graphics.Color.YELLOW, "Pink" to 0xFFFF80AB.toInt(), "Blue" to 0xFF80D8FF.toInt()).forEach { (name, value) -> + FilterChip(selected = color == value, onClick = { color = value }, label = { Text(name) }) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = background, onCheckedChange = { background = it }); Text("Text background") } + } }, + confirmButton = { TextButton(onClick = { val text = input?.text?.toString()?.trim().orEmpty(); if (text.isNotEmpty()) onDone(initial.copy(text = text, style = style, alignment = alignment, color = color, background = background)) }) { Text("Done") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** IME-provided sticker URIs are copied immediately into the draft's private storage. */ +private class StickerEditText(context: Context) : EditText(context) { + var onStickerReceived: (Uri, () -> Unit) -> Unit = { _, finished -> finished() } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connection = super.onCreateInputConnection(outAttrs) ?: return null + EditorInfoCompat.setContentMimeTypes(outAttrs, arrayOf("image/png", "image/jpeg", "image/webp")) + @Suppress("DEPRECATION") + return InputConnectionCompat.createWrapper(connection, outAttrs) { content, flags, _ -> + if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) { + try { content.requestPermission() } catch (_: Exception) { return@createWrapper false } + } + onStickerReceived(content.contentUri) { runCatching { content.releasePermission() } } + true + } + } + override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.paste) { + val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).primaryClip + val uri = if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null + if (uri != null) { onStickerReceived(uri) {}; return true } + } + return super.onTextContextMenuItem(id) + } +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditorStore.kt b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditorStore.kt new file mode 100644 index 0000000..24d0916 --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryEditorStore.kt @@ -0,0 +1,168 @@ +package tech.stygian.presently.editor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.util.AtomicFile +import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import tech.stygian.presently.camera.JpegOrientationNormalizer +import tech.stygian.presently.data.LocalStoryDraft +import tech.stygian.presently.data.PresentlyDao +import tech.stygian.presently.story.ATProtoTid +import java.io.File +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID + +internal class StoryEditorStore(private val context: Context, private val dao: PresentlyDao) { + private val mutex = Mutex() + private val root = File(context.filesDir, "story-editor") + private fun directory(id: String): File = File(root, id).also { it.mkdirs() } + private fun original(draft: LocalStoryDraft): File = File(directory(draft.id), "original.jpg") + private fun rendered(draft: LocalStoryDraft): File = File(directory(draft.id), "rendered.jpg") + + suspend fun restore(): LocalStoryDraft? = withContext(Dispatchers.IO) { dao.latestUnfinishedDraft() } + + suspend fun create(bytes: ByteArray): LocalStoryDraft = withContext(Dispatchers.IO) { + mutex.withLock { + val draft = LocalStoryDraft(imageData = byteArrayOf(), originalImagePath = "original.jpg", compositionJson = StoryComposition().encode()) + atomicWrite(original(draft), bytes) + dao.upsertDraft(draft) + draft + } + } + + suspend fun source(draft: LocalStoryDraft): ByteArray = withContext(Dispatchers.IO) { + if (draft.originalImagePath != null) original(draft).readBytes() else draft.imageData + } + + suspend fun save(draft: LocalStoryDraft, composition: StoryComposition): LocalStoryDraft = withContext(Dispatchers.IO) { + mutex.withLock { + check(draft.renderedImagePath == null && draft.recordKey == null) { "Posted attempts cannot be edited. Retry or discard this story." } + draft.copy(compositionJson = composition.encode()).also { dao.upsertDraft(it) } + } + } + + suspend fun freeze(draft: LocalStoryDraft, composition: StoryComposition): Pair = withContext(Dispatchers.IO) { + mutex.withLock { + if (draft.renderedImagePath != null) return@withLock draft to rendered(draft).readBytes() + // Existing pre-editor attempts also retain their exact original bytes and record key. + val bytes = if (draft.recordKey != null && draft.compositionJson == null) draft.imageData else { + val bitmap = decodeBounded(source(draft), 2048) + val assets = loadAssets(draft, composition) + try { StoryRenderer.render(bitmap, composition, assets) } + finally { bitmap.recycle(); assets.values.forEach { it.recycle() } } + } + atomicWrite(rendered(draft), bytes) + val frozen = draft.copy( + compositionJson = composition.encode(), renderedImagePath = "rendered.jpg", + recordKey = draft.recordKey ?: ATProtoTid.create(), + ) + dao.upsertDraft(frozen) + frozen to bytes + } + } + + suspend fun discard(draft: LocalStoryDraft) = withContext(Dispatchers.IO) { + mutex.withLock { dao.deleteDraft(draft.id); directory(draft.id).deleteRecursively(); Unit } + } + + suspend fun importSticker(draft: LocalStoryDraft, uri: Uri): String = withContext(Dispatchers.IO) { + val bytes = context.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArrayOutputStream() + val block = ByteArray(8192) + while (true) { + val size = input.read(block) + if (size < 0) break + require(buffer.size() + size <= 8 * 1024 * 1024) { "Choose a sticker smaller than 8 MiB." } + buffer.write(block, 0, size) + } + buffer.toByteArray() + } ?: error("This image could not be opened.") + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + require(options.outMimeType in listOf("image/png", "image/jpeg", "image/webp")) { "Choose a static PNG, JPEG, or WebP image." } + require(!isAnimatedImage(bytes)) { "Animated stickers aren’t supported yet. Choose a still image." } + val decoded = decodeBounded(bytes, 512) + val orientation = runCatching { ExifInterface(ByteArrayInputStream(bytes)).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + val transform = JpegOrientationNormalizer.transformFor(orientation) + val bitmap = if (transform.isIdentity) decoded else Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, Matrix().apply { + setRotate(transform.rotationDegrees); postScale(transform.horizontalScale, transform.verticalScale) + }, true) + if (bitmap !== decoded) decoded.recycle() + val name = "${UUID.randomUUID()}.png" + try { + val encoded = ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) + output.toByteArray() + } + atomicWrite(File(directory(draft.id), name), encoded) + } finally { bitmap.recycle() } + name + } + + suspend fun loadAssets(draft: LocalStoryDraft, composition: StoryComposition): Map = withContext(Dispatchers.IO) { + val loaded = mutableMapOf() + try { + val names = composition.layers.mapNotNull { it.asset }.distinct() + require(names.size <= 10) { "A story can have up to 10 image stickers." } + names.forEach { name -> + require(name.matches(Regex("[a-zA-Z0-9-]+\\.png"))) + loaded[name] = decodeBounded(File(directory(draft.id), name).readBytes(), 512) + } + loaded + } catch (error: Throwable) { + loaded.values.forEach { it.recycle() } + throw error + } + } + + companion object { + fun decodeBounded(bytes: ByteArray, maximumDimension: Int): Bitmap { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + require(options.outWidth in 1..32000 && options.outHeight in 1..32000) { "This image has unsupported dimensions." } + options.inJustDecodeBounds = false + options.inSampleSize = 1 + while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > maximumDimension) options.inSampleSize *= 2 + return requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)) { "This image could not be decoded." } + } + + internal fun isAnimatedImage(bytes: ByteArray): Boolean { + fun tag(offset: Int, value: String): Boolean = offset >= 0 && offset + value.length <= bytes.size && + value.indices.all { bytes[offset + it].toInt() and 255 == value[it].code } + fun unsignedInt(offset: Int, littleEndian: Boolean): Long { + if (offset + 4 > bytes.size) return -1 + return (0..3).fold(0L) { result, index -> + result or ((bytes[offset + index].toLong() and 255L) shl (if (littleEndian) index * 8 else (3 - index) * 8)) + } + } + if (tag(0, "GIF")) return true + val png = bytes.size >= 8 && bytes[0] == 0x89.toByte() && tag(1, "PNG") + val webp = tag(0, "RIFF") && tag(8, "WEBP") + if (!png && !webp) return false + var offset = if (png) 8 else 12 + while (offset.toLong() + 8 <= bytes.size.toLong()) { + val length = unsignedInt(if (png) offset else offset + 4, webp) + if (tag(if (png) offset + 4 else offset, if (png) "acTL" else "ANIM")) return true + val next = offset.toLong() + length + (if (png) 12 else 8) + (if (webp) length % 2 else 0) + if (length < 0 || next > bytes.size || next <= offset) break + offset = next.toInt() + } + return false + } + + private fun atomicWrite(file: File, bytes: ByteArray) { + val atomic = AtomicFile(file) + val output = atomic.startWrite() + try { output.write(bytes); atomic.finishWrite(output) } + catch (error: Throwable) { atomic.failWrite(output); throw error } + } + } +} diff --git a/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryRenderer.kt b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryRenderer.kt new file mode 100644 index 0000000..af62968 --- /dev/null +++ b/apps/android/app/src/main/java/tech/stygian/presently/editor/StoryRenderer.kt @@ -0,0 +1,91 @@ +package tech.stygian.presently.editor + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.util.LruCache +import kotlin.math.ceil +import java.io.ByteArrayOutputStream +import tech.stygian.presently.story.FlashesStoryContract + +/** Preview and export share layout, fonts, wrapping, transforms, and clipping. */ +internal object StoryRenderer { + private data class TextLayoutKey(val text: String, val width: Float, val color: Int, val style: String, val alignment: String) + private val textLayouts = LruCache(64) + private val imagePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + private fun textLayout(layer: StoryLayer, width: Float): StaticLayout { + val key = TextLayoutKey(layer.text, width, layer.color, layer.style, layer.alignment) + textLayouts.get(key)?.let { return it } + val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = layer.color + textSize = width * 0.065f + typeface = when (layer.style) { + "mono" -> Typeface.MONOSPACE + "classic" -> Typeface.SERIF + else -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + } + val naturalWidth = layer.text.split('\n').maxOfOrNull { paint.measureText(it) } ?: 0f + val layoutWidth = ceil(minOf(width * 0.8f, naturalWidth)).toInt().coerceAtLeast(1) + return StaticLayout.Builder.obtain(layer.text, 0, layer.text.length, paint, layoutWidth) + .setAlignment(when (layer.alignment) { + "left" -> Layout.Alignment.ALIGN_NORMAL + "right" -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_CENTER + }) + .setIncludePad(true) + .build().also { textLayouts.put(key, it) } + } + + fun bounds(layer: StoryLayer, width: Float, assets: Map): RectF { + val bitmap = layer.asset?.let { assets[it] } + val w = if (layer.asset == null) textLayout(layer, width).width.toFloat() else width * 0.32f + val h = if (layer.asset == null) textLayout(layer, width).height.toFloat() + else bitmap?.let { w * it.height / it.width } ?: w + return RectF(-w / 2, -h / 2, w / 2, h / 2) + } + + fun drawLayers(canvas: Canvas, composition: StoryComposition, width: Float, height: Float, assets: Map) { + composition.layers.forEach { layer -> + canvas.save() + canvas.translate(layer.x * width, layer.y * height) + canvas.rotate(layer.rotation) + canvas.scale(layer.scale, layer.scale) + val bounds = bounds(layer, width, assets) + if (layer.asset != null) { + assets[layer.asset]?.let { canvas.drawBitmap(it, null, bounds, imagePaint) } + } else { + if (layer.background) canvas.drawRoundRect(bounds, width * 0.012f, width * 0.012f, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (layer.color == Color.BLACK) 0xD9FFFFFF.toInt() else 0xB3000000.toInt() }) + canvas.translate(bounds.left, bounds.top) + textLayout(layer, width).draw(canvas) + } + canvas.restore() + } + } + + fun render(source: Bitmap, composition: StoryComposition, assets: Map): ByteArray { + val factor = minOf(1f, 2048f / maxOf(source.width, source.height)) + val output = Bitmap.createBitmap((source.width * factor).toInt().coerceAtLeast(1), (source.height * factor).toInt().coerceAtLeast(1), Bitmap.Config.ARGB_8888) + try { + val canvas = Canvas(output) + canvas.drawColor(Color.BLACK) + canvas.drawBitmap(source, null, RectF(0f, 0f, output.width.toFloat(), output.height.toFloat()), Paint(Paint.FILTER_BITMAP_FLAG)) + drawLayers(canvas, composition, output.width.toFloat(), output.height.toFloat(), assets) + for (quality in listOf(95, 85, 75)) { + val bytes = ByteArrayOutputStream().use { stream -> + check(output.compress(Bitmap.CompressFormat.JPEG, quality, stream)) { "The story could not be rendered." } + stream.toByteArray() + } + if (bytes.size <= FlashesStoryContract.MaximumImageBytes) return bytes + } + error("The edited story exceeds the 10 MiB limit.") + } finally { output.recycle() } + } +} diff --git a/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryCompositionTest.kt b/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryCompositionTest.kt new file mode 100644 index 0000000..34c8b07 --- /dev/null +++ b/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryCompositionTest.kt @@ -0,0 +1,34 @@ +package tech.stygian.presently.editor + +import org.junit.Assert.* +import org.junit.Test + +class StoryCompositionTest { + @Test fun aspectFitKeepsLetterboxingOutOfCoordinates() { + assertEquals(ImageFrame(100f, 0f, 300f, 600f), ImageFrame.fit(100, 200, 500, 600)) + assertEquals(ImageFrame(0f, 200f, 400f, 200f), ImageFrame.fit(200, 100, 400, 600)) + } + @Test fun transformedLayerClampsReachableBoundsAndScale() { + val layer = StoryLayer(x = 0.8f, y = 0.1f, rotation = 350f).transformed(5f, -2f, 100f, 30f) + assertEquals(1f, layer.x); assertEquals(0f, layer.y) + assertEquals(5f, layer.scale); assertEquals(20f, layer.rotation) + val local = layerLocalPoint(StoryLayer(rotation = 90f, scale = 2f), 50f, 120f, 100f, 200f) + assertEquals(10f, local.first, 0.001f); assertEquals(0f, local.second, 0.001f) + } + @Test fun documentRoundTripPreservesEditableTextStyleAndTransforms() { + val composition = StoryComposition(listOf( + StoryLayer(text = "Hello 👩🏽‍🚀\nمرحبا", x = 0.2f, y = 0.7f, scale = 1.7f, rotation = 21f, color = 123, style = "mono", alignment = "right", background = true), + StoryLayer(asset = "sticker-1.png"), + )) + assertEquals(composition, StoryComposition.decode(composition.encode())) + assertThrows(IllegalArgumentException::class.java) { StoryComposition.decode("{\"version\":2,\"layers\":[]}") } + assertThrows(IllegalArgumentException::class.java) { StoryComposition.decode(composition.encode().replace("sticker-1.png", "../../secret.png")) } + } + @Test fun animationDetectionReadsContainerChunksNotArbitraryMetadata() { + assertFalse(StoryEditorStore.isAnimatedImage("JPEG EXIF ANIMAL acTL".toByteArray())) + val pngSignature = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 13, 10, 26, 10) + assertTrue(StoryEditorStore.isAnimatedImage(pngSignature + byteArrayOf(0, 0, 0, 8) + "acTL".toByteArray() + ByteArray(12))) + assertFalse(StoryEditorStore.isAnimatedImage(pngSignature + byteArrayOf(0, 0, 0, 4) + "tEXt".toByteArray() + "ANIM".toByteArray() + ByteArray(4))) + assertTrue(StoryEditorStore.isAnimatedImage("RIFF".toByteArray() + byteArrayOf(12, 0, 0, 0) + "WEBPANIM".toByteArray() + ByteArray(4))) + } +} diff --git a/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryEditorAndroidTest.kt b/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryEditorAndroidTest.kt new file mode 100644 index 0000000..b2652e8 --- /dev/null +++ b/apps/android/app/src/test/java/tech/stygian/presently/editor/StoryEditorAndroidTest.kt @@ -0,0 +1,174 @@ +package tech.stygian.presently.editor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.net.Uri +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import tech.stygian.presently.data.PresentlyDatabase +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.UUID +import kotlin.math.abs + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class StoryEditorAndroidTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + private fun photo(): ByteArray { + val bitmap = Bitmap.createBitmap(400, 600, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) } + return try { ByteArrayOutputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it); it.toByteArray() } } + finally { bitmap.recycle() } + } + + @Test fun originalAndEditsSurviveDatabaseReopenAndFrozenRetryUsesIdenticalBytes() = runBlocking { + val name = "editor-${UUID.randomUUID()}.db" + var database = Room.databaseBuilder(context, PresentlyDatabase::class.java, name).build() + var store = StoryEditorStore(context, database.presentlyDao()) + var draft = store.create(photo()) + val composition = StoryComposition(listOf(StoryLayer(text = "Saved text", x = 0.3f, background = true))) + draft = store.save(draft, composition) + database.close() + database = Room.databaseBuilder(context, PresentlyDatabase::class.java, name).build() + store = StoryEditorStore(context, database.presentlyDao()) + try { + val restored = requireNotNull(store.restore()) + assertEquals(draft.id, restored.id) + assertEquals(composition, StoryComposition.decode(restored.compositionJson)) + assertArrayEquals(photo(), store.source(restored)) + val (frozen, bytes) = store.freeze(restored, composition) + val retryStore = StoryEditorStore(context, database.presentlyDao()) + val retry = requireNotNull(retryStore.restore()) + val (again, retryBytes) = retryStore.freeze(retry, StoryComposition()) + assertEquals(frozen.recordKey, again.recordKey) + assertArrayEquals(bytes, retryBytes) + assertTrue(bytes.size <= 10_485_760) + try { retryStore.save(again, StoryComposition()); fail("A frozen story must reject edits") } catch (_: IllegalStateException) { } + store.discard(again) + assertNull(store.restore()) + } finally { database.close(); context.deleteDatabase(name) } + } + + @Test fun transparentImportCopiesAssetIntoDraftAndEnforcesPixelBudget() = runBlocking { + val database = Room.inMemoryDatabaseBuilder(context, PresentlyDatabase::class.java).build() + val store = StoryEditorStore(context, database.presentlyDao()) + val draft = store.create(photo()) + val file = File(context.cacheDir, "sticker-${UUID.randomUUID()}.png") + val sticker = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.TRANSPARENT); setPixel(0, 0, Color.RED) } + file.outputStream().use { sticker.compress(Bitmap.CompressFormat.PNG, 100, it) }; sticker.recycle() + try { + val name = store.importSticker(draft, Uri.fromFile(file)) + file.delete() + val assets = store.loadAssets(draft, StoryComposition(listOf(StoryLayer(asset = name)))) + val image = assets.getValue(name) + assertTrue(maxOf(image.width, image.height) <= 512) + assertEquals(0, Color.alpha(image.getPixel(image.width / 2, image.height / 2))) + assets.values.forEach { it.recycle() } + } finally { file.delete(); store.discard(draft); database.close() } + } + + @Test fun previewMatchesExportUsingCanonicalTextLayoutAndLetterboxedImage() { + val source = BitmapFactory.decodeByteArray(photo(), 0, photo().size) + val sticker = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + val composition = StoryComposition(listOf( + StoryLayer(text = "A line that wraps predictably across this story 👋", y = 0.2f, color = Color.YELLOW, background = true), + StoryLayer(asset = "red.png", x = 0.7f, y = 0.65f, rotation = 25f), + )) + val assets = mapOf("red.png" to sticker) + val encoded = StoryRenderer.render(source, composition, assets) + val exported = BitmapFactory.decodeByteArray(encoded, 0, encoded.size) + for ((viewWidth, viewHeight) in listOf(400 to 800, 213 to 500)) { + val view = StoryCanvasView(context).apply { + this.source = source; this.assets = assets; this.composition = composition; editable = false + layout(0, 0, viewWidth, viewHeight) + } + val preview = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888) + view.draw(Canvas(preview)) + val expected = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888) + val frame = ImageFrame.fit(source.width, source.height, viewWidth, viewHeight) + Canvas(expected).drawBitmap(exported, null, android.graphics.RectF(frame.left, frame.top, frame.left + frame.width, frame.top + frame.height), android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG)) + var difference = 0L + for (y in 0 until viewHeight) for (x in 0 until viewWidth) { + val a = preview.getPixel(x, y); val b = expected.getPixel(x, y) + difference += abs(Color.red(a) - Color.red(b)) + abs(Color.green(a) - Color.green(b)) + abs(Color.blue(a) - Color.blue(b)) + } + val meanError = difference / (viewWidth.toDouble() * viewHeight * 3) + assertTrue("Preview/export color error at width $viewWidth was $meanError", meanError < 6) + assertEquals(Color.TRANSPARENT, preview.getPixel(0, 0)) + preview.recycle(); expected.recycle() + } + assertTrue(Color.red(exported.getPixel(280, 390)) > 230) + listOf(source, sticker, exported).forEach { it.recycle() } + } + + @Test fun shortEmojiHasAContentSizedSelectionTarget() { + val bounds = StoryRenderer.bounds(StoryLayer(text = "✨"), 400f, emptyMap()) + assertTrue(bounds.width() in 1f..80f) + } + + @Test fun completedDragCommitsOnceAndBlocksPublishingUntilCommit() { + val source = BitmapFactory.decodeByteArray(photo(), 0, photo().size) + val initial = StoryComposition(listOf(StoryLayer(text = "Move me"))) + val changes = mutableListOf() + val events = mutableListOf() + val view = StoryCanvasView(context).apply { + this.source = source; composition = initial + onChange = { changes.add(it); events.add("commit") } + onTransforming = { events.add(if (it) "begin" else "end") } + layout(0, 0, 400, 800) + } + fun touch(action: Int, x: Float, y: Float, time: Long) { + val event = android.view.MotionEvent.obtain(0, time, action, x, y, 0) + view.onTouchEvent(event); event.recycle() + } + touch(android.view.MotionEvent.ACTION_DOWN, 200f, 400f, 0) + touch(android.view.MotionEvent.ACTION_MOVE, 240f, 460f, 100) + assertTrue(changes.isEmpty()) + assertTrue(view.transforming) + touch(android.view.MotionEvent.ACTION_UP, 240f, 460f, 200) + assertEquals(listOf("begin", "commit", "end"), events) + assertEquals(1, changes.size) + assertEquals(0.6f, changes.single().layers.single().x, 0.001f) + assertEquals(0.6f, changes.single().layers.single().y, 0.001f) + assertFalse(view.transforming) + source.recycle() + } + + @Test fun migrationPreservesExistingDraftBytesAndPublicationIdentity() { + val name = "migration-${UUID.randomUUID()}.db" + fun helper(version: Int) = FrameworkSQLiteOpenHelperFactory().create(SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name).callback(object : SupportSQLiteOpenHelper.Callback(version) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE local_story_drafts (id TEXT PRIMARY KEY NOT NULL, imageData BLOB NOT NULL, recordKey TEXT)") + db.execSQL("INSERT INTO local_story_drafts VALUES ('original', X'010203', 'existing-key')") + } + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { PresentlyDatabase.Migration4To5.migrate(db) } + }).build()) + helper(4).also { it.writableDatabase; it.close() } + val migrated = helper(5) + try { + migrated.writableDatabase.query("SELECT * FROM local_story_drafts").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertArrayEquals(byteArrayOf(1, 2, 3), cursor.getBlob(cursor.getColumnIndexOrThrow("imageData"))) + assertEquals("existing-key", cursor.getString(cursor.getColumnIndexOrThrow("recordKey"))) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("compositionJson"))) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("originalImagePath"))) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("renderedImagePath"))) + } + } finally { migrated.close(); context.deleteDatabase(name) } + } +} diff --git a/apps/ios/Presently.xcodeproj/project.pbxproj b/apps/ios/Presently.xcodeproj/project.pbxproj index 860e1b0..7038fc8 100644 --- a/apps/ios/Presently.xcodeproj/project.pbxproj +++ b/apps/ios/Presently.xcodeproj/project.pbxproj @@ -3,13 +3,18 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ + 0C121AEE1CF6386F855092E6 /* StoryTextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41C45B53C53E246EA9804B /* StoryTextInput.swift */; }; 0C1573C30B6FD77141ECEBF5 /* FlashesStoryRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F537D1774090DDECC6E492C8 /* FlashesStoryRecordTests.swift */; }; + 0F9C370347CC0361E7302754 /* StoryComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62481BC31C3F8DBA733D2E9 /* StoryComposition.swift */; }; + 25B37D7C4897DAC9A7599907 /* StoryEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C738DB5C2138E00C6AA542FE /* StoryEditorView.swift */; }; 2664589237E03538648B2130 /* PresentlyControls.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FB069B312F53097B44D5EF86 /* PresentlyControls.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 26E867E829158B03932A9968 /* ATProtoOAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C19C46E316A7D27E6499A79 /* ATProtoOAuthClient.swift */; }; + 2947C1820D4E0DD6FEC9C101 /* StoryEditorCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD382856D200FF9C777AA2EA /* StoryEditorCanvas.swift */; }; + 2EBB75D6007025BDCEE0466A /* StoryEditorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF69D540A9417880CE76E50F /* StoryEditorSession.swift */; }; 428DDC5896099565A7E6850D /* PresentlyCaptureExtension.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = 18C62D3DA51BDD4AC3DF41E6 /* PresentlyCaptureExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4C26DFF826ACC8C48C0BD58F /* OAuthDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BAB1E6E2F59642B42E3DE4D /* OAuthDiscovery.swift */; }; 506175B529C0909BFBB4B11D /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E908AE01B1048CAAC2648097 /* CameraPreview.swift */; }; @@ -30,6 +35,7 @@ B6D37D564714A50AA0EA7E0E /* OAuthConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93D49D37F46FF3BE1FFD887 /* OAuthConfiguration.swift */; }; B7BFDC8B45903445BA66CDF0 /* PresentlyCaptureIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D74BC3F0298491C9E8CD0 /* PresentlyCaptureIntent.swift */; }; D0FD446C52945AEE4065469F /* OAuthHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1B2A90BD8252C5B347D830 /* OAuthHTTPClient.swift */; }; + D221195AF11100171BE62F50 /* StoryEditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0CAB4F02AEAF3453270FA8 /* StoryEditorTests.swift */; }; D68601EA6797BAEF382CFEAE /* PresentlyCaptureIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D74BC3F0298491C9E8CD0 /* PresentlyCaptureIntent.swift */; }; E0BF4B9B9F3209926B8B4BAF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F962FC7879277CC62A48833F /* Assets.xcassets */; }; E78D545C82E1DE0880055715 /* FlashesActorProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21055B48D762713167C90C /* FlashesActorProfile.swift */; }; @@ -92,10 +98,11 @@ 18C62D3DA51BDD4AC3DF41E6 /* PresentlyCaptureExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = PresentlyCaptureExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 25E42A34F1CF11B3B82A9245 /* AccountSearchClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSearchClient.swift; sourceTree = ""; }; 2BAB1E6E2F59642B42E3DE4D /* OAuthDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthDiscovery.swift; sourceTree = ""; }; + 2D0CAB4F02AEAF3453270FA8 /* StoryEditorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryEditorTests.swift; sourceTree = ""; }; 2E1CF365EE89DFFCFB0B0465 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 2EA704E436BCFF051125B370 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = ""; }; 373A5090A3325512D060C6B7 /* PresentlyCaptureExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentlyCaptureExtension.swift; sourceTree = ""; }; - 38B62273AD256B7E1308AEF0 /* PresentlyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PresentlyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 38B62273AD256B7E1308AEF0 /* PresentlyTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PresentlyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 510D74BC3F0298491C9E8CD0 /* PresentlyCaptureIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentlyCaptureIntent.swift; sourceTree = ""; }; 58AF028905EE7821A59A9F1B /* OAuthSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSessionManager.swift; sourceTree = ""; }; 5C19C46E316A7D27E6499A79 /* ATProtoOAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATProtoOAuthClient.swift; sourceTree = ""; }; @@ -103,13 +110,17 @@ 6872B4AA1FDE60AA6459AB4F /* CameraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraController.swift; sourceTree = ""; }; 71700D3CF062076280CC22F1 /* OAuthSecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSecureStore.swift; sourceTree = ""; }; 7E1B2A90BD8252C5B347D830 /* OAuthHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthHTTPClient.swift; sourceTree = ""; }; + 8D41C45B53C53E246EA9804B /* StoryTextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryTextInput.swift; sourceTree = ""; }; A08F3917CA9B236EDF9ADC2C /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; A478D1D9DDB0115BF9F408EF /* DPoP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoP.swift; sourceTree = ""; }; + A62481BC31C3F8DBA733D2E9 /* StoryComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryComposition.swift; sourceTree = ""; }; ADB40AE90080E041CD96530D /* LocalStoryDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStoryDraft.swift; sourceTree = ""; }; B1AB4782F27C889A7252EA9C /* FlashesStoryRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashesStoryRecord.swift; sourceTree = ""; }; BDB871280117FB16ADE67DF4 /* CameraScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraScreen.swift; sourceTree = ""; }; BE2B605D28A456F04965115E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - C2C8752D8D776FE4AC5DB389 /* Presently.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Presently.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BF69D540A9417880CE76E50F /* StoryEditorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryEditorSession.swift; sourceTree = ""; }; + C2C8752D8D776FE4AC5DB389 /* Presently.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Presently.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C738DB5C2138E00C6AA542FE /* StoryEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryEditorView.swift; sourceTree = ""; }; DA21055B48D762713167C90C /* FlashesActorProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashesActorProfile.swift; sourceTree = ""; }; E15815BF2DCA11AFA272D709 /* PhotoLibrarySaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibrarySaver.swift; sourceTree = ""; }; E908AE01B1048CAAC2648097 /* CameraPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; @@ -118,8 +129,9 @@ F163D2FE11E08BD65E5170BE /* PresentlyLockedCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentlyLockedCameraView.swift; sourceTree = ""; }; F537D1774090DDECC6E492C8 /* FlashesStoryRecordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashesStoryRecordTests.swift; sourceTree = ""; }; F962FC7879277CC62A48833F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - FB069B312F53097B44D5EF86 /* PresentlyControls.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PresentlyControls.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FB069B312F53097B44D5EF86 /* PresentlyControls.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PresentlyControls.appex; sourceTree = BUILT_PRODUCTS_DIR; }; FCF0E7842C334255D73722EF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + FD382856D200FF9C777AA2EA /* StoryEditorCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryEditorCanvas.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -157,6 +169,7 @@ isa = PBXGroup; children = ( F537D1774090DDECC6E492C8 /* FlashesStoryRecordTests.swift */, + 2D0CAB4F02AEAF3453270FA8 /* StoryEditorTests.swift */, ); path = PresentlyTests; sourceTree = ""; @@ -214,6 +227,18 @@ path = Resources; sourceTree = ""; }; + 920621AE176A0528F275BA33 /* Editor */ = { + isa = PBXGroup; + children = ( + A62481BC31C3F8DBA733D2E9 /* StoryComposition.swift */, + FD382856D200FF9C777AA2EA /* StoryEditorCanvas.swift */, + BF69D540A9417880CE76E50F /* StoryEditorSession.swift */, + C738DB5C2138E00C6AA542FE /* StoryEditorView.swift */, + 8D41C45B53C53E246EA9804B /* StoryTextInput.swift */, + ); + path = Editor; + sourceTree = ""; + }; 92480E04970DF1B621F83FF0 /* Story */ = { isa = PBXGroup; children = ( @@ -237,6 +262,7 @@ children = ( 431518B881E2A760E9F6EB59 /* App */, 046296247CBE04EF60C85956 /* Camera */, + 920621AE176A0528F275BA33 /* Editor */, A1BA7DA1B9F570DE894FF817 /* Models */, 4BF575BA8D23C5A50070A896 /* OAuth */, 8843A36C5D39217A0B9914C2 /* Resources */, @@ -371,6 +397,7 @@ ); mainGroup = 41FDD0E6053CDBDADD4CF0BB; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; projectDirPath = ""; projectRoot = ""; targets = ( @@ -418,6 +445,11 @@ 76500A7F963D25248439F0B2 /* PhotoLibrarySaver.swift in Sources */, A961ED2B4BC75548D1D59381 /* PresentlyApp.swift in Sources */, B7BFDC8B45903445BA66CDF0 /* PresentlyCaptureIntent.swift in Sources */, + 0F9C370347CC0361E7302754 /* StoryComposition.swift in Sources */, + 2947C1820D4E0DD6FEC9C101 /* StoryEditorCanvas.swift in Sources */, + 2EBB75D6007025BDCEE0466A /* StoryEditorSession.swift in Sources */, + 25B37D7C4897DAC9A7599907 /* StoryEditorView.swift in Sources */, + 0C121AEE1CF6386F855092E6 /* StoryTextInput.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -426,6 +458,7 @@ buildActionMask = 2147483647; files = ( 0C1573C30B6FD77141ECEBF5 /* FlashesStoryRecordTests.swift in Sources */, + D221195AF11100171BE62F50 /* StoryEditorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -539,14 +572,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 15; INFOPLIST_FILE = Presently/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -615,14 +646,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 15; INFOPLIST_FILE = Presently/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -638,7 +667,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.controls"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.controls; SDKROOT = iphoneos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -655,7 +684,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.controls"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.controls; SDKROOT = iphoneos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -671,7 +700,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.capture"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.capture; SDKROOT = iphoneos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -688,7 +717,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.tests"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.tests; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Presently.app/Presently"; @@ -705,7 +734,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.tests"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.tests; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Presently.app/Presently"; @@ -721,7 +750,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "tech.stygian.presently-app.capture"; + PRODUCT_BUNDLE_IDENTIFIER = tech.stygian.presently.capture; SDKROOT = iphoneos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/apps/ios/Presently/Camera/CameraScreen.swift b/apps/ios/Presently/Camera/CameraScreen.swift index 0fa4604..6939e3c 100644 --- a/apps/ios/Presently/Camera/CameraScreen.swift +++ b/apps/ios/Presently/Camera/CameraScreen.swift @@ -14,6 +14,8 @@ struct CameraScreen: View { @State private var zoomAtGestureStart: Double? @State private var isPublishing = false @State private var activeDraft: LocalStoryDraft? + @State private var editor: StoryEditorSession? + @Environment(\.scenePhase) private var scenePhase private var settings: AppSettings? { storedSettings.first @@ -23,15 +25,15 @@ struct CameraScreen: View { ZStack { Color.black.ignoresSafeArea() - if let imageData = camera.capturedPhotoData, - let image = UIImage(data: imageData) { - review(image: image, imageData: imageData) + if let editor { + review(editor: editor) } else { liveCamera } } .task { ensureSettingsExist() + restoreDraft() let defaultCamera = settings?.defaultCamera ?? .back if #available(iOS 18.0, *) { try? await PresentlyCaptureIntent.updateAppContext( @@ -44,6 +46,16 @@ struct CameraScreen: View { preferredFacing: defaultCamera.cameraFacing ) } + .onChange(of: camera.capturedPhotoData) { _, data in + guard let data else { return } + beginDraft(data: data) + } + .onChange(of: scenePhase) { _, phase in + if phase != .active, let editor { + editor.endTransform() + persistComposition(editor.composition) + } + } .onChange(of: settings?.defaultCamera) { _, preference in guard let preference else { return } Task { @@ -256,64 +268,105 @@ struct CameraScreen: View { } } - private func review(image: UIImage, imageData: Data) -> some View { - ZStack { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity, maxHeight: .infinity) + private func review(editor: StoryEditorSession) -> some View { + VStack(spacing: 0) { + StoryEditorView(session: editor, isEditable: !isPublishing && activeDraft?.recordKey == nil) { + feedbackMessage = $0 + } + VStack(spacing: 14) { + saveToPhotosReviewOption + HStack { + Button("Discard", systemImage: "xmark", role: .cancel) { discardDraft() } + .disabled(isPublishing || !editor.canFinishEditing) + Spacer() + if isPublishing { ProgressView().tint(.white) } + Button(isPublishing ? "Posting…" : "Post Photo", systemImage: "checkmark") { + Task { await prepareAndPublish() } + } + .buttonStyle(.borderedProminent) + .tint(.green) + .disabled(isPublishing || !editor.canFinishEditing) + } + } + .padding() + .background(.regularMaterial) + } + .preferredColorScheme(.dark) + } - VStack { - Spacer() + private func attachEditor(to draft: LocalStoryDraft) throws { + draft.preserveOriginalForEditing() + let session = try StoryEditorSession(photoData: draft.originalImageData ?? draft.imageData, recipe: draft.editorData) + activeDraft = draft + session.onChange = { composition in persistComposition(composition) } + editor = session + } - VStack(spacing: 18) { - saveToPhotosReviewOption - - HStack(spacing: 56) { - Button(role: .cancel) { - saveThisPhoto = false - activeDraft = nil - camera.retake() - } label: { - Image(systemName: "xmark") - .font(.title2.bold()) - .foregroundStyle(.white) - .frame(width: 64, height: 64) - .background(.black.opacity(0.72), in: Circle()) - } - .disabled(isPublishing) - .accessibilityLabel("Don’t Post Photo") - .accessibilityHint("Returns to the camera") + private func beginDraft(data: Data) { + // A new camera capture owns a new draft; originals are never replaced by the flattened export. + do { + let draft = LocalStoryDraft(imageData: data) + draft.originalImageData = data + draft.editorData = try StoryComposition().encoded() + try attachEditor(to: draft) + modelContext.insert(draft) + try modelContext.save() + } catch { feedbackMessage = "Could not save your draft: \(error.localizedDescription)" } + } - Button { - Task { - await publishStory(imageData: imageData) - } - } label: { - if isPublishing { - ProgressView() - .tint(.white) - .frame(width: 64, height: 64) - .background(.green, in: Circle()) - } else { - Image(systemName: "checkmark") - .font(.title2.bold()) - .foregroundStyle(.white) - .frame(width: 64, height: 64) - .background(.green, in: Circle()) - } - } - .disabled(isPublishing) - .accessibilityLabel( - isPublishing ? "Posting Photo" : "Post Photo" - ) - } + private func restoreDraft() { + guard editor == nil else { return } + do { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + if let draft = try modelContext.fetch(descriptor).first(where: { $0.state != .published }) { + if draft.state == .publishing { draft.state = .failed } + try attachEditor(to: draft) + try modelContext.save() + return + } + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--editor-demo") { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 900, height: 1200)) + let image = renderer.image { context in + UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 900, height: 1200)) + UIColor.systemIndigo.setFill(); context.fill(CGRect(x: 0, y: 750, width: 900, height: 450)) } - .padding() - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22)) - .padding() + if let data = image.jpegData(compressionQuality: 0.9) { beginDraft(data: data) } } - } + #endif + } catch { feedbackMessage = "Could not restore your pending photo: \(error.localizedDescription)" } + } + + private func persistComposition(_ composition: StoryComposition) { + guard let draft = activeDraft, draft.recordKey == nil else { return } + do { + draft.editorData = try composition.encoded() + try modelContext.save() + } catch { feedbackMessage = "Your latest edits could not be saved: \(error.localizedDescription)" } + } + + private func discardDraft() { + if let draft = activeDraft { modelContext.delete(draft) } + do { try modelContext.save() } + catch { feedbackMessage = "Could not discard your draft: \(error.localizedDescription)"; return } + activeDraft = nil + editor = nil + saveThisPhoto = false + camera.retake() + } + + @MainActor + private func prepareAndPublish() async { + guard let editor, let draft = activeDraft, editor.canFinishEditing, !isPublishing else { return } + guard auth.session?.canPublishStory == true else { presentedSheet = .account; return } + do { + if draft.recordKey == nil { + draft.editorData = try editor.composition.encoded() + draft.imageData = try StoryRenderer.jpeg(photo: editor.photo, composition: editor.composition) + try modelContext.save() + } + await publishStory(imageData: draft.imageData) + } catch { feedbackMessage = "Could not prepare your photo: \(error.localizedDescription)" } } private func ensureSettingsExist() { @@ -402,6 +455,7 @@ struct CameraScreen: View { draft = activeDraft draft.state = .publishing draft.lastError = nil + if draft.recordKey == nil { draft.recordKey = ATProtoTID.make() } } else { draft = LocalStoryDraft( imageData: imageData, @@ -452,6 +506,7 @@ struct CameraScreen: View { feedbackMessage = "Your story is live." } activeDraft = nil + editor = nil saveThisPhoto = false camera.retake() } catch { diff --git a/apps/ios/Presently/Editor/StoryComposition.swift b/apps/ios/Presently/Editor/StoryComposition.swift new file mode 100644 index 0000000..6f75e9c --- /dev/null +++ b/apps/ios/Presently/Editor/StoryComposition.swift @@ -0,0 +1,186 @@ +import Foundation +import ImageIO +import UIKit + +struct StoryComposition: Codable, Equatable { + static let currentVersion = 1 + var version = currentVersion + var layers: [StoryLayer] = [] + + var fitsAssetBudget: Bool { + let images = layers.compactMap(\.stickerPNG) + guard images.reduce(0, { $0 + $1.count }) <= 8 * 1_024 * 1_024 else { return false } + var pixels = 0 + for data in images { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= 1_024, height <= 1_024 else { return false } + pixels += width * height + } + return pixels <= 6 * 1_024 * 1_024 + } + + func encoded() throws -> Data { + guard fitsAssetBudget else { throw EditorError.imageTooLarge } + return try JSONEncoder().encode(self) + } + + static func decode(_ data: Data?) throws -> Self { + guard let data else { return Self() } + let value = try JSONDecoder().decode(Self.self, from: data) + guard value.version == currentVersion, value.layers.count <= 30, + value.layers.allSatisfy(\.isValid), value.fitsAssetBudget else { throw EditorError.invalidDraft } + return value + } +} + +struct StoryLayer: Codable, Identifiable, Equatable { + enum Style: String, Codable, CaseIterable { case bold, classic, mono } + enum Alignment: String, Codable, CaseIterable { case left, center, right } + var id = UUID() + var text: String = "" + var isEmoji: Bool = false + var stickerPNG: Data? + var style: Style = .bold + var color: String = "white" + var alignment: Alignment = .center + var background = false + // Position and width are relative to the upright photo, independent of its viewport. + var x: Double = 0.5 + var y: Double = 0.5 + var width: Double = 0.65 + var rotation: Double = 0 + + var isValid: Bool { + x.isFinite && y.isFinite && width.isFinite && rotation.isFinite && + (0...1).contains(x) && (0...1).contains(y) && (0.08...1.5).contains(width) && + text.count <= 500 && (stickerPNG?.count ?? 0) <= 8 * 1_024 * 1_024 + } +} + +enum EditorError: LocalizedError { + case invalidImage, animatedSticker, invalidDraft, imageTooLarge, tooManyLayers + var errorDescription: String? { + switch self { + case .invalidImage: "This image could not be opened." + case .animatedSticker: "Choose a still image for this sticker." + case .invalidDraft: "This draft uses an unsupported or damaged editor format." + case .imageTooLarge: "This image is too large. Choose a smaller image." + case .tooManyLayers: "A photo can contain up to 30 text and sticker layers." + } + } +} + +@MainActor +enum StoryRenderer { + static let colors: [(String, UIColor)] = [ + ("white", .white), ("black", .black), ("yellow", .systemYellow), + ("pink", .systemPink), ("blue", .systemBlue), ("green", .systemGreen) + ] + + static func uprightImage(_ data: Data, maximumDimension: Int = 2_048, sticker: Bool = false) throws -> UIImage { + guard data.count <= 20 * 1_024 * 1_024, + let source = CGImageSourceCreateWithData(data as CFData, nil) else { throw EditorError.invalidImage } + if sticker && CGImageSourceGetCount(source) > 1 { throw EditorError.animatedSticker } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumDimension, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + throw EditorError.invalidImage + } + return UIImage(cgImage: cgImage) + } + + static func layerImage(_ layer: StoryLayer) -> UIImage { + if let data = layer.stickerPNG, let image = UIImage(data: data) { return image } + if layer.isEmoji { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 160, height: 160), format: format).image { _ in + (layer.text as NSString).draw(in: CGRect(x: 8, y: 0, width: 150, height: 160), withAttributes: [.font: UIFont.systemFont(ofSize: 132)]) + } + } + let font: UIFont + switch layer.style { + case .bold: font = .systemFont(ofSize: 64, weight: .heavy) + case .classic: font = .systemFont(ofSize: 64, weight: .regular) + case .mono: font = .monospacedSystemFont(ofSize: 64, weight: .semibold) + } + let paragraph = NSMutableParagraphStyle() + switch layer.alignment { + case .left: paragraph.alignment = .left + case .center: paragraph.alignment = .center + case .right: paragraph.alignment = .right + } + paragraph.lineBreakMode = .byWordWrapping + let color = colors.first { $0.0 == layer.color }?.1 ?? .white + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: color, .paragraphStyle: paragraph + ] + let string = NSAttributedString(string: layer.text, attributes: attributes) + let content = string.boundingRect(with: CGSize(width: 700, height: 50_000), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) + // Fixed text box gives alignment a meaningful width and identical line wrapping everywhere. + let size = CGSize(width: 748, height: max(100, ceil(content.height) + 32)) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let rasterScale = min(1, 1_024 / size.height) + let rasterSize = CGSize(width: size.width * rasterScale, height: size.height * rasterScale) + return UIGraphicsImageRenderer(size: rasterSize, format: format).image { context in + context.cgContext.scaleBy(x: rasterScale, y: rasterScale) + if layer.background { + (layer.color == "black" ? UIColor.white : UIColor.black).withAlphaComponent(0.8).setFill() + UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 18).fill() + } + string.draw(with: CGRect(x: 24, y: 16, width: 700, height: size.height - 32), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) + } + } + + static func layerSize(_ layer: StoryLayer, image: UIImage, photoSize: CGSize) -> CGSize { + let width = photoSize.width * layer.width + return CGSize(width: width, height: width * image.size.height / image.size.width) + } + + static func drawLayer(_ layer: StoryLayer, image: UIImage, photoRect: CGRect, context: CGContext) { + let size = layerSize(layer, image: image, photoSize: photoRect.size) + context.saveGState() + context.translateBy(x: photoRect.minX + photoRect.width * layer.x, y: photoRect.minY + photoRect.height * layer.y) + context.rotate(by: layer.rotation) + image.draw(in: CGRect(x: -size.width / 2, y: -size.height / 2, width: size.width, height: size.height)) + context.restoreGState() + } + + static func render(photo: UIImage, composition: StoryComposition) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = true + return UIGraphicsImageRenderer(size: photo.size, format: format).image { context in + let rect = CGRect(origin: .zero, size: photo.size) + UIColor.black.setFill() + context.fill(rect) + photo.draw(in: rect) + for layer in composition.layers { + drawLayer(layer, image: layerImage(layer), photoRect: rect, context: context.cgContext) + } + } + } + + static func jpeg(photo: UIImage, composition: StoryComposition) throws -> Data { + let rendered = render(photo: photo, composition: composition) + for quality in [0.9, 0.75, 0.6] { + if let data = rendered.jpegData(compressionQuality: quality), data.count <= FlashesStoryContract.maximumImageBytes { return data } + } + throw EditorError.imageTooLarge + } + + static func photoRect(imageSize: CGSize, bounds: CGRect) -> CGRect { + guard imageSize.width > 0, imageSize.height > 0 else { return .zero } + let scale = min(bounds.width / imageSize.width, bounds.height / imageSize.height) + let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + return CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height) + } +} diff --git a/apps/ios/Presently/Editor/StoryEditorCanvas.swift b/apps/ios/Presently/Editor/StoryEditorCanvas.swift new file mode 100644 index 0000000..5d73f7e --- /dev/null +++ b/apps/ios/Presently/Editor/StoryEditorCanvas.swift @@ -0,0 +1,189 @@ +import SwiftUI +import UIKit + +struct StoryEditorCanvas: UIViewRepresentable { + let session: StoryEditorSession + var isEditable: Bool + + func makeUIView(context: Context) -> EditorCanvasView { EditorCanvasView(session: session) } + func updateUIView(_ view: EditorCanvasView, context: Context) { + // Reading observable properties here schedules UIKit redraws on model changes. + view.update(composition: session.composition, selection: session.selectedID, editable: isEditable) + } +} + +@MainActor +final class EditorCanvasView: UIView, UIGestureRecognizerDelegate { + private let session: StoryEditorSession + private var images: [UUID: (StoryLayer, UIImage)] = [:] + private var activeGestures = Set() + private var showVerticalGuide = false + private var showHorizontalGuide = false + private var composition = StoryComposition() + private var selection: UUID? + private var editable = true + private var photoRect: CGRect { StoryRenderer.photoRect(imageSize: session.photo.size, bounds: bounds) } + + init(session: StoryEditorSession) { + self.session = session + super.init(frame: .zero) + backgroundColor = .black + isMultipleTouchEnabled = true + let gestures: [UIGestureRecognizer] = [ + UITapGestureRecognizer(target: self, action: #selector(tap(_:))), + UIPanGestureRecognizer(target: self, action: #selector(pan(_:))), + UIPinchGestureRecognizer(target: self, action: #selector(pinch(_:))), + UIRotationGestureRecognizer(target: self, action: #selector(rotate(_:))) + ] + for gesture in gestures { gesture.delegate = self; addGestureRecognizer(gesture) } + accessibilityIdentifier = "storyEditorCanvas" + isAccessibilityElement = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func update(composition: StoryComposition, selection: UUID?, editable: Bool) { + self.composition = composition + self.selection = selection + self.editable = editable + for layer in composition.layers { + // Transform-only changes reuse their raster; expensive text layout happens only on content edits. + var content = layer + content.x = 0; content.y = 0; content.width = 1; content.rotation = 0 + if images[layer.id]?.0 != content { images[layer.id] = (content, StoryRenderer.layerImage(layer)) } + } + images = images.filter { id, _ in composition.layers.contains { $0.id == id } } + rebuildAccessibility() + setNeedsDisplay() + } + + override func layoutSubviews() { super.layoutSubviews(); rebuildAccessibility(); setNeedsDisplay() } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + let photo = photoRect + session.photo.draw(in: photo) + context.saveGState() + context.clip(to: photo) + for layer in composition.layers { + guard let image = images[layer.id]?.1 else { continue } + StoryRenderer.drawLayer(layer, image: image, photoRect: photo, context: context) + if editable && selection == layer.id { + let size = StoryRenderer.layerSize(layer, image: image, photoSize: photo.size) + context.saveGState() + context.translateBy(x: photo.minX + photo.width * layer.x, y: photo.minY + photo.height * layer.y) + context.rotate(by: layer.rotation) + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(1.5) + context.setLineDash(phase: 0, lengths: [5, 4]) + context.stroke(CGRect(x: -size.width / 2 - 3, y: -size.height / 2 - 3, width: size.width + 6, height: size.height + 6)) + context.restoreGState() + } + } + context.setStrokeColor(UIColor.systemYellow.cgColor) + context.setLineWidth(1) + if showVerticalGuide { context.move(to: CGPoint(x: photo.midX, y: photo.minY)); context.addLine(to: CGPoint(x: photo.midX, y: photo.maxY)); context.strokePath() } + if showHorizontalGuide { context.move(to: CGPoint(x: photo.minX, y: photo.midY)); context.addLine(to: CGPoint(x: photo.maxX, y: photo.midY)); context.strokePath() } + context.restoreGState() + } + + private func layer(at point: CGPoint) -> StoryLayer? { + guard photoRect.contains(point) else { return nil } + return composition.layers.reversed().first { layer in + guard let image = images[layer.id]?.1 else { return false } + let size = StoryRenderer.layerSize(layer, image: image, photoSize: photoRect.size) + let dx = point.x - (photoRect.minX + photoRect.width * layer.x) + let dy = point.y - (photoRect.minY + photoRect.height * layer.y) + let localX = dx * cos(layer.rotation) + dy * sin(layer.rotation) + let localY = -dx * sin(layer.rotation) + dy * cos(layer.rotation) + return abs(localX) <= max(22, size.width / 2) && abs(localY) <= max(22, size.height / 2) + } + } + + @objc private func tap(_ recognizer: UITapGestureRecognizer) { + guard editable else { return } + session.selectedID = layer(at: recognizer.location(in: self))?.id + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard editable else { return false } + if gestureRecognizer is UITapGestureRecognizer { return true } + if activeGestures.isEmpty { + guard let hit = layer(at: gestureRecognizer.location(in: self)) else { return false } + session.selectedID = hit.id + } + return session.selectedID != nil + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + !(gestureRecognizer is UITapGestureRecognizer) && !(otherGestureRecognizer is UITapGestureRecognizer) + } + + private func track(_ recognizer: UIGestureRecognizer) { + if recognizer.state == .began { + activeGestures.insert(ObjectIdentifier(recognizer)) + session.beginTransform() + } + if [.ended, .cancelled, .failed].contains(recognizer.state) { + activeGestures.remove(ObjectIdentifier(recognizer)) + if activeGestures.isEmpty { + if let layer = session.selectedLayer { + session.transform(id: layer.id, x: showVerticalGuide ? 0.5 : nil, y: showHorizontalGuide ? 0.5 : nil) + } + session.endTransform() + showVerticalGuide = false; showHorizontalGuide = false + setNeedsDisplay() + } + } + } + + @objc private func pan(_ recognizer: UIPanGestureRecognizer) { + track(recognizer) + guard editable, recognizer.state == .began || recognizer.state == .changed, + let layer = session.selectedLayer, photoRect.width > 0 else { return } + let delta = recognizer.translation(in: self) + let x = layer.x + delta.x / photoRect.width + let y = layer.y + delta.y / photoRect.height + // Guides snap on gesture completion; incremental pan remains smooth across the center. + showVerticalGuide = abs(x - 0.5) < 0.012 + showHorizontalGuide = abs(y - 0.5) < 0.012 + session.transform(id: layer.id, x: x, y: y) + recognizer.setTranslation(.zero, in: self) + } + + @objc private func pinch(_ recognizer: UIPinchGestureRecognizer) { + track(recognizer) + guard editable, recognizer.state == .began || recognizer.state == .changed, let layer = session.selectedLayer else { return } + session.transform(id: layer.id, width: layer.width * recognizer.scale) + recognizer.scale = 1 + } + + @objc private func rotate(_ recognizer: UIRotationGestureRecognizer) { + track(recognizer) + guard editable, recognizer.state == .began || recognizer.state == .changed, let layer = session.selectedLayer else { return } + session.transform(id: layer.id, rotation: layer.rotation + recognizer.rotation) + recognizer.rotation = 0 + } + + private func rebuildAccessibility() { + accessibilityElements = composition.layers.map { layer in + let element = EditorLayerAccessibilityElement(accessibilityContainer: self) + element.accessibilityLabel = layer.stickerPNG == nil ? layer.text : "Image sticker" + element.accessibilityTraits = [.button] + element.accessibilityHint = "Select to edit, move, resize, rotate, or delete using layer controls" + let center = CGPoint(x: photoRect.minX + photoRect.width * layer.x, y: photoRect.minY + photoRect.height * layer.y) + element.accessibilityFrameInContainerSpace = CGRect(x: center.x - 30, y: center.y - 30, width: 60, height: 60) + element.activate = { [weak self] in + guard let self, self.editable else { return false } + self.session.selectedID = layer.id + return true + } + return element + } + } +} + +private final class EditorLayerAccessibilityElement: UIAccessibilityElement { + var activate: (() -> Bool)? + override func accessibilityActivate() -> Bool { activate?() ?? false } +} diff --git a/apps/ios/Presently/Editor/StoryEditorSession.swift b/apps/ios/Presently/Editor/StoryEditorSession.swift new file mode 100644 index 0000000..e71646f --- /dev/null +++ b/apps/ios/Presently/Editor/StoryEditorSession.swift @@ -0,0 +1,89 @@ +import Observation +import UIKit + +@MainActor @Observable +final class StoryEditorSession { + let photo: UIImage + private(set) var composition: StoryComposition + var selectedID: UUID? + var isImporting = false + private(set) var undoStack: [StoryComposition] = [] + private var gestureStart: StoryComposition? + var onChange: ((StoryComposition) -> Void)? + + init(photoData: Data, recipe: Data?) throws { + photo = try StoryRenderer.uprightImage(photoData) + composition = try StoryComposition.decode(recipe) + } + + var selectedLayer: StoryLayer? { composition.layers.first { $0.id == selectedID } } + var canUndo: Bool { !undoStack.isEmpty } + var isTransforming: Bool { gestureStart != nil } + var canFinishEditing: Bool { !isImporting && !isTransforming } + + func add(_ layer: StoryLayer) throws { + guard composition.layers.count < 30 else { throw EditorError.tooManyLayers } + var proposed = composition + proposed.layers.append(layer) + guard proposed.fitsAssetBudget else { throw EditorError.imageTooLarge } + checkpoint() + composition = proposed + selectedID = layer.id + onChange?(composition) + } + + func update(_ layer: StoryLayer) { + guard let index = composition.layers.firstIndex(where: { $0.id == layer.id }) else { return } + checkpoint() + composition.layers[index] = layer + onChange?(composition) + } + + func deleteSelected() { + guard let selectedID else { return } + checkpoint() + composition.layers.removeAll { $0.id == selectedID } + self.selectedID = nil + onChange?(composition) + } + + func bringToFront() { + guard let layer = selectedLayer else { return } + checkpoint() + composition.layers.removeAll { $0.id == layer.id } + composition.layers.append(layer) + onChange?(composition) + } + + func beginTransform() { + if gestureStart == nil { gestureStart = composition } + } + + func transform(id: UUID, x: Double? = nil, y: Double? = nil, width: Double? = nil, rotation: Double? = nil) { + guard gestureStart != nil else { return } + guard let index = composition.layers.firstIndex(where: { $0.id == id }) else { return } + if let x { composition.layers[index].x = min(1, max(0, x)) } + if let y { composition.layers[index].y = min(1, max(0, y)) } + if let width { composition.layers[index].width = min(1.5, max(0.08, width)) } + if let rotation { composition.layers[index].rotation = rotation.truncatingRemainder(dividingBy: .pi * 2) } + } + + func endTransform() { + guard let start = gestureStart else { return } + gestureStart = nil + if start != composition { appendUndo(start); onChange?(composition) } + } + + func undo() { + guard let previous = undoStack.popLast() else { return } + composition = previous + selectedID = nil + onChange?(composition) + } + + private func checkpoint() { appendUndo(composition) } + private func appendUndo(_ value: StoryComposition) { + undoStack.append(value) + if undoStack.count > 30 { undoStack.removeFirst() } + } +} diff --git a/apps/ios/Presently/Editor/StoryEditorView.swift b/apps/ios/Presently/Editor/StoryEditorView.swift new file mode 100644 index 0000000..d0d4ceb --- /dev/null +++ b/apps/ios/Presently/Editor/StoryEditorView.swift @@ -0,0 +1,224 @@ +import PhotosUI +import SwiftUI +import UIKit + +struct StoryEditorView: View { + let session: StoryEditorSession + var isEditable: Bool + var onError: (String) -> Void + @State private var editingLayer: StoryLayer? + @State private var selectedPhoto: PhotosPickerItem? + @State private var showEmoji = false + @State private var showTransform = false + + + var body: some View { + VStack(spacing: 0) { + ScrollView(.horizontal) { + HStack(spacing: 18) { + Button("Text", systemImage: "textformat") { editingLayer = StoryLayer() } + Button("Emoji", systemImage: "face.smiling") { showEmoji = true } + PhotosPicker(selection: $selectedPhoto, matching: .images, preferredItemEncoding: .current) { + Label("Sticker", systemImage: "photo.on.rectangle") + } + Button("Undo", systemImage: "arrow.uturn.backward") { session.undo() } + .disabled(!session.canUndo) + } + .padding() + } + .disabled(!isEditable || session.isImporting) + .background(.black.opacity(0.7)) + + StoryEditorCanvas(session: session, isEditable: isEditable) + .overlay { + if session.isImporting { ProgressView("Adding sticker…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } + } + + if isEditable, let layer = session.selectedLayer { + ScrollView(.horizontal) { + HStack(spacing: 20) { + if layer.stickerPNG == nil && !layer.isEmoji { + Button("Edit", systemImage: "pencil") { editingLayer = layer } + } + Button("Adjust", systemImage: "slider.horizontal.3") { showTransform = true } + Button("To front", systemImage: "square.3.layers.3d.top.filled") { session.bringToFront() } + Button("Delete", systemImage: "trash", role: .destructive) { session.deleteSelected() } + } + .padding() + } + } else { + Text(isEditable ? "Drag to move · Pinch to resize · Twist to rotate" : "This version is saved for retrying your post.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(10) + } + } + .foregroundStyle(.white) + .sheet(item: $editingLayer) { layer in + StoryTextSheet(layer: layer, existingComposition: session.composition, saveSticker: { data in + addSticker(data) + }, onError: onError) { updated in + do { + if session.composition.layers.contains(where: { $0.id == updated.id }) { session.update(updated) } + else { try session.add(updated) } + } catch { onError(error.localizedDescription) } + } + } + .sheet(isPresented: $showEmoji) { + NavigationStack { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 64))]) { + ForEach(["😀", "🥰", "😂", "😎", "🥳", "❤️", "✨", "🔥", "🌈", "☀️", "🌻", "🎉", "🐶", "🐱", "🦋", "☕️", "🍕", "📍", "💯", "🙌"], id: \.self) { emoji in + Button { + var layer = StoryLayer(text: emoji, isEmoji: true) + layer.width = 0.3 + do { try session.add(layer) } catch { onError(error.localizedDescription) } + showEmoji = false + } label: { Text(emoji).font(.largeTitle).frame(minWidth: 64, minHeight: 64) } + } + }.padding() + Text("Use Text and your keyboard for more emoji.").font(.footnote).foregroundStyle(.secondary) + } + .navigationTitle("Emoji") + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showEmoji = false } } } + } + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showTransform) { + if let layer = session.selectedLayer { + StoryTransformSheet(layer: layer) { session.update($0) } + } + } + .onChange(of: selectedPhoto) { _, item in + guard let item else { return } + session.isImporting = true + Task { + defer { session.isImporting = false; selectedPhoto = nil } + do { + guard let file = try await item.loadTransferable(type: StoryStickerFile.self) else { throw EditorError.invalidImage } + addSticker(file.data) + } catch { onError(error.localizedDescription) } + } + } + } + private func addSticker(_ data: Data) { + do { + let image = try StoryRenderer.uprightImage(data, maximumDimension: 1_024, sticker: true) + guard let png = image.pngData(), png.count <= 8 * 1_024 * 1_024 else { throw EditorError.imageTooLarge } + var layer = StoryLayer(stickerPNG: png) + layer.width = 0.4 + try session.add(layer) + } catch { onError(error.localizedDescription) } + } +} + +private struct StoryTextSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var layer: StoryLayer + @State private var pendingStickers: [Data] = [] + @State private var textInput = StoryTextInputHandle() + let existingComposition: StoryComposition + let saveSticker: (Data) -> Void + let onError: (String) -> Void + let save: (StoryLayer) -> Void + + init(layer: StoryLayer, existingComposition: StoryComposition, saveSticker: @escaping (Data) -> Void, onError: @escaping (String) -> Void, save: @escaping (StoryLayer) -> Void) { + _layer = State(initialValue: layer) + self.existingComposition = existingComposition + self.saveSticker = saveSticker + self.onError = onError + self.save = save + } + + var body: some View { + NavigationStack { + Form { + Section("Text") { + StoryTextInput(text: $layer.text, handle: textInput, onSticker: { data in + do { + guard pendingStickers.count < 30 else { throw EditorError.tooManyLayers } + let image = try StoryRenderer.uprightImage(data, maximumDimension: 1_024, sticker: true) + guard let png = image.pngData() else { throw EditorError.invalidImage } + var proposed = existingComposition + proposed.layers += (pendingStickers + [png]).map { StoryLayer(stickerPNG: $0) } + guard proposed.layers.count <= 30, proposed.fitsAssetBudget else { throw EditorError.imageTooLarge } + pendingStickers.append(png) + } catch { onError(error.localizedDescription) } + }, onError: onError) + .frame(minHeight: 100) + if !pendingStickers.isEmpty { + Text("\(pendingStickers.count) keyboard sticker(s) ready to add").font(.caption) + } + Text("\(layer.text.count)/500 characters").font(.caption).foregroundStyle(.secondary) + } + Section("Style") { + Picker("Font", selection: $layer.style) { + ForEach(StoryLayer.Style.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) } + } + Picker("Alignment", selection: $layer.alignment) { + ForEach(StoryLayer.Alignment.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) } + } + Picker("Color", selection: $layer.color) { + ForEach(StoryRenderer.colors, id: \.0) { color in + Text(color.0.capitalized).tag(color.0) + } + } + Toggle("Background", isOn: $layer.background) + } + Section("Preview") { + Image(uiImage: StoryRenderer.layerImage(layer)) + .resizable().scaledToFit().frame(maxHeight: 140) + .frame(maxWidth: .infinity) + .background(.gray.opacity(0.35)) + .accessibilityHidden(true) + } + } + .navigationTitle("Text") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + var committedLayer = layer + committedLayer.text = textInput.committedText() ?? layer.text + if !committedLayer.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { save(committedLayer) } + for sticker in pendingStickers { saveSticker(sticker) } + dismiss() + } + .disabled(layer.text.count > 500) + } + } + + } + } +} + +private struct StoryTransformSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var layer: StoryLayer + let save: (StoryLayer) -> Void + init(layer: StoryLayer, save: @escaping (StoryLayer) -> Void) { + _layer = State(initialValue: layer) + self.save = save + } + var body: some View { + NavigationStack { + Form { + Section("Position") { + Slider(value: $layer.x, in: 0...1) { Text("Horizontal position") } + Slider(value: $layer.y, in: 0...1) { Text("Vertical position") } + Button("Center") { layer.x = 0.5; layer.y = 0.5 } + } + Section("Size and rotation") { + Slider(value: $layer.width, in: 0.08...1.5) { Text("Size") } + Slider(value: $layer.rotation, in: -.pi...Double.pi) { Text("Rotation") } + } + } + .navigationTitle("Adjust layer") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Done") { save(layer); dismiss() } } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/apps/ios/Presently/Editor/StoryTextInput.swift b/apps/ios/Presently/Editor/StoryTextInput.swift new file mode 100644 index 0000000..4af2707 --- /dev/null +++ b/apps/ios/Presently/Editor/StoryTextInput.swift @@ -0,0 +1,159 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +// UIKit exposes keyboard stickers and adaptive image glyphs on iOS 18 while +// retaining native selection, dictation and the text editing menu on iOS 17. +struct StoryTextInput: UIViewRepresentable { + @Binding var text: String + var handle: StoryTextInputHandle? = nil + let onSticker: (Data) -> Void + let onError: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.backgroundColor = .clear + view.delegate = context.coordinator + view.pasteDelegate = context.coordinator + view.allowsEditingTextAttributes = true + if #available(iOS 18.0, *) { view.supportsAdaptiveImageGlyph = true } + view.accessibilityLabel = "Sticker text" + view.text = text + handle?.textView = view + handle?.coordinator = context.coordinator + return view + } + func updateUIView(_ view: UITextView, context: Context) { + context.coordinator.parent = self + context.coordinator.synchronize(view, modelText: text) + } + + @MainActor final class Coordinator: NSObject, UITextViewDelegate, UITextPasteDelegate { + var parent: StoryTextInput + init(_ parent: StoryTextInput) { self.parent = parent } + + func synchronize(_ textView: UITextView, modelText: String) { + // SwiftUI may deliver an older binding snapshot between native keystrokes. + // While editing, UIKit owns the text and its marked/selected ranges. + guard !textView.isFirstResponder, textView.markedTextRange == nil, + textView.text != modelText else { return } + let selection = textView.selectedRange + textView.text = modelText + restoreSelection(selection, in: textView) + } + + private func restoreSelection(_ selection: NSRange, in textView: UITextView) { + let length = textView.text.utf16.count + let location = min(selection.location, length) + textView.selectedRange = NSRange(location: location, length: min(selection.length, length - location)) + } + + func textViewDidEndEditing(_ textView: UITextView) { + textViewDidChange(textView) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if textView.markedTextRange != nil { return true } + guard let swiftRange = Range(range, in: textView.text) else { return false } + let proposed = textView.text.replacingCharacters(in: swiftRange, with: text) + guard proposed.count <= 500 else { + parent.onError("Text can contain up to 500 characters.") + return false + } + return true + } + + func textViewDidChange(_ textView: UITextView) { + // Do not normalize or publish an IME's provisional text. Committing it + // produces another delegate change with no marked range. + guard textView.markedTextRange == nil else { return } + let selection = textView.selectedRange + let text = NSMutableAttributedString(attributedString: textView.attributedText) + var ranges: [NSRange] = [] + text.enumerateAttributes(in: NSRange(location: 0, length: text.length)) { attributes, range, _ in + if #available(iOS 18.0, *), let glyph = attributes[.adaptiveImageGlyph] as? NSAdaptiveImageGlyph { + parent.onSticker(glyph.imageContent) + ranges.append(range) + } else if let attachment = attributes[.attachment] as? NSTextAttachment { + if let data = attachment.contents ?? attachment.image?.pngData() { parent.onSticker(data) } + else { parent.onError("This keyboard sticker could not be opened. Try adding it from Photos.") } + ranges.append(range) + } + } + for range in ranges.reversed() { text.deleteCharacters(in: range) } + if !ranges.isEmpty { + let mapOffset: (Int) -> Int = { offset in + offset - ranges.reduce(0) { removed, range in + removed + min(range.length, max(0, offset - range.location)) + } + } + let start = mapOffset(selection.location) + let end = mapOffset(NSMaxRange(selection)) + textView.attributedText = text + restoreSelection(NSRange(location: start, length: max(0, end - start)), in: textView) + } + let bounded = String(text.string.prefix(500)) + if text.string.count > 500 { + let boundedSelection = textView.selectedRange + textView.text = bounded + restoreSelection(boundedSelection, in: textView) + parent.onError("Text can contain up to 500 characters.") + } + parent.text = bounded + } + + func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting, transform item: any UITextPasteItem) { + let provider = item.itemProvider + guard let identifier = provider.registeredTypeIdentifiers.first(where: { UTType($0)?.conforms(to: .image) == true }) else { + item.setDefaultResult() + return + } + item.setNoResult() + provider.loadFileRepresentation(forTypeIdentifier: identifier) { [weak self] url, error in + let result: Result = Result { + guard let url else { throw error ?? EditorError.invalidImage } + return try StoryStickerFile.read(url) + } + Task { @MainActor in + guard let self else { return } + switch result { + case let .success(data): self.parent.onSticker(data) + case let .failure(error): self.parent.onError(error.localizedDescription) + } + } + } + } + } +} + +@MainActor +final class StoryTextInputHandle { + weak var textView: UITextView? + weak var coordinator: StoryTextInput.Coordinator? + + /// A toolbar action can run before SwiftUI consumes the final delegate update. + /// Read the native editor synchronously, committing any IME marked text first. + func committedText() -> String? { + guard let textView else { return nil } + textView.unmarkText() + coordinator?.textViewDidChange(textView) + return String(textView.text.prefix(500)) + } +} + +struct StoryStickerFile: Transferable { + let data: Data + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(importedContentType: .image) { received in + StoryStickerFile(data: try read(received.file)) + } + } + static func read(_ url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max + guard size <= 20 * 1_024 * 1_024 else { throw EditorError.imageTooLarge } + return try Data(contentsOf: url) + } +} diff --git a/apps/ios/Presently/Models/LocalStoryDraft.swift b/apps/ios/Presently/Models/LocalStoryDraft.swift index 1262b2f..481da3d 100644 --- a/apps/ios/Presently/Models/LocalStoryDraft.swift +++ b/apps/ios/Presently/Models/LocalStoryDraft.swift @@ -12,6 +12,8 @@ final class LocalStoryDraft { @Attribute(.unique) var id: UUID @Attribute(.externalStorage) var imageData: Data + @Attribute(.externalStorage) var originalImageData: Data? + @Attribute(.externalStorage) var editorData: Data? var createdAt: Date var stateRawValue: String var lastError: String? @@ -24,6 +26,12 @@ final class LocalStoryDraft { set { stateRawValue = newValue.rawValue } } + /// Older pending and locked-camera drafts only stored imageData. Preserve it + /// before the first export so restoring never applies layers to a flattened photo. + func preserveOriginalForEditing() { + if originalImageData == nil { originalImageData = imageData } + } + init( id: UUID = UUID(), imageData: Data, diff --git a/apps/ios/PresentlyTests/StoryEditorTests.swift b/apps/ios/PresentlyTests/StoryEditorTests.swift new file mode 100644 index 0000000..b3a6c17 --- /dev/null +++ b/apps/ios/PresentlyTests/StoryEditorTests.swift @@ -0,0 +1,237 @@ +import ImageIO +import SwiftData +import SwiftUI +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import Presently + +@MainActor +struct StoryEditorTests { + private func solid(_ color: UIColor, size: CGSize = CGSize(width: 200, height: 400)) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: size, format: format).image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } + + @Test func letterboxedGeometryUsesPhotoNotScreen() { + let rect = StoryRenderer.photoRect(imageSize: CGSize(width: 400, height: 200), bounds: CGRect(x: 0, y: 0, width: 300, height: 600)) + #expect(rect == CGRect(x: 0, y: 225, width: 300, height: 150)) + let portrait = StoryRenderer.photoRect(imageSize: CGSize(width: 200, height: 400), bounds: CGRect(x: 0, y: 0, width: 300, height: 600)) + #expect(portrait == CGRect(x: 0, y: 0, width: 300, height: 600)) + } + + @Test func transformsAreBoundedUndoableAndPersistOncePerGesture() throws { + let session = try StoryEditorSession(photoData: #require(solid(.blue).pngData()), recipe: nil) + let layer = StoryLayer(text: "Hello") + try session.add(layer) + var persisted: [StoryComposition] = [] + session.onChange = { persisted.append($0) } + #expect(session.canFinishEditing) + session.beginTransform() + #expect(!session.canFinishEditing) + session.beginTransform() + session.transform(id: layer.id, x: -2, y: 3, width: 20, rotation: .pi * 5) + session.transform(id: layer.id, x: 0.4, y: 0.6) + #expect(persisted.isEmpty) + session.endTransform() + #expect(persisted.count == 1) + #expect(session.canFinishEditing) + let ended = session.composition + session.transform(id: layer.id, x: 0.9) + #expect(session.composition == ended) + #expect(session.selectedLayer?.width == 1.5) + #expect(session.selectedLayer?.rotation == .pi) + session.undo() + #expect(session.composition.layers == [layer]) + #expect(persisted.count == 2) + } + + @Test func textStyleAndTransparentStickerRoundTrip() throws { + var text = StoryLayer(text: "Two lines\nHello 🌈") + text.style = .mono; text.color = "pink"; text.background = true; text.alignment = .right + text.x = 0.25; text.rotation = 0.35 + let stickerData: Data = try #require(solid(.clear, size: CGSize(width: 10, height: 20)).pngData()) + let sticker = StoryLayer(stickerPNG: stickerData) + let composition = StoryComposition(layers: [text, sticker]) + #expect(try StoryComposition.decode(composition.encoded()) == composition) + var future = composition + future.version = 99 + #expect(throws: EditorError.self) { try StoryComposition.decode(future.encoded()) } + } + + @Test func compositePreservesTransparencyStackingAndOutputSize() throws { + let format = UIGraphicsImageRendererFormat(); format.scale = 1 + let sticker = UIGraphicsImageRenderer(size: CGSize(width: 100, height: 100), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 50, y: 0, width: 50, height: 100)) + } + let stickerData: Data = try #require(sticker.pngData()) + var layer = StoryLayer(stickerPNG: stickerData) + layer.width = 0.5 + let photo = solid(.blue) + let rendered = StoryRenderer.render(photo: photo, composition: StoryComposition(layers: [layer])) + #expect(rendered.size == photo.size) + #expect(pixel(rendered, x: 70, y: 200).blue > 250) + #expect(pixel(rendered, x: 120, y: 200).red > 250) + let topData: Data = try #require(solid(.green, size: CGSize(width: 10, height: 10)).pngData()) + var top = StoryLayer(stickerPNG: topData) + top.width = 0.1 + let stacked = StoryRenderer.render(photo: photo, composition: StoryComposition(layers: [layer, top])) + #expect(pixel(stacked, x: 100, y: 200).green > 250) + let jpeg = try StoryRenderer.jpeg(photo: photo, composition: StoryComposition(layers: [layer])) + #expect(jpeg.count < FlashesStoryContract.maximumImageBytes) + #expect(UIImage(data: jpeg)?.size == photo.size) + } + + @Test func importedEXIFOrientationIsAppliedBeforeGeometry() throws { + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try #require(solid(.red, size: CGSize(width: 80, height: 40)).cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + let image = try StoryRenderer.uprightImage(data as Data) + #expect(image.imageOrientation == .up) + #expect(image.size == CGSize(width: 40, height: 80)) + } + + @Test func aggregateDecodedStickerBudgetPreventsUnboundedCanvasMemory() throws { + let data = try #require(solid(.blue, size: CGSize(width: 1024, height: 1024)).pngData()) + let session = try StoryEditorSession(photoData: #require(solid(.white).pngData()), recipe: nil) + for _ in 0..<6 { try session.add(StoryLayer(stickerPNG: data)) } + #expect(throws: EditorError.self) { try session.add(StoryLayer(stickerPNG: data)) } + #expect(session.composition.layers.count == 6) + #expect(!StoryComposition(layers: (0..<7).map { _ in StoryLayer(stickerPNG: data) }).fitsAssetBudget) + } + + @Test func legacyDraftPreservesOriginalAcrossExportAndStoreReopen() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("store") + defer { for suffix in ["", "-shm", "-wal"] { try? FileManager.default.removeItem(atPath: url.path + suffix) } } + let configuration = ModelConfiguration(url: url) + let original = try #require(solid(.blue).jpegData(compressionQuality: 0.9)) + let recipe = StoryComposition(layers: [StoryLayer(text: "Saved draft")]) + let exported: Data + do { + let container = try ModelContainer(for: LocalStoryDraft.self, configurations: configuration) + let context = ModelContext(container) + let draft = LocalStoryDraft(imageData: original) + draft.preserveOriginalForEditing() + let session = try StoryEditorSession(photoData: #require(draft.originalImageData), recipe: nil) + exported = try StoryRenderer.jpeg(photo: session.photo, composition: recipe) + draft.imageData = exported + draft.editorData = try recipe.encoded() + draft.recordKey = "stable-retry-key" + draft.state = .failed + context.insert(draft) + try context.save() + } + let reopened = try ModelContainer(for: LocalStoryDraft.self, configurations: configuration) + let context = ModelContext(reopened) + let draft = try #require(context.fetch(FetchDescriptor()).first) + #expect(draft.originalImageData == original) + #expect(draft.imageData == exported) + #expect(draft.recordKey == "stable-retry-key") + let restored = try StoryEditorSession(photoData: #require(draft.originalImageData), recipe: draft.editorData) + #expect(restored.composition == recipe) + #expect(try StoryRenderer.jpeg(photo: restored.photo, composition: restored.composition) == exported) + } + + @Test func emojiRasterFillsStickerBoxAndLongTextRasterIsBounded() { + let emoji = StoryRenderer.layerImage(StoryLayer(text: "🌈", isEmoji: true)) + #expect(emoji.size == CGSize(width: 160, height: 160)) + let long = StoryRenderer.layerImage(StoryLayer(text: String(repeating: "a\n", count: 250))) + #expect(long.size.height <= 1024) + #expect(long.size.width <= 1024) + } + + private func pixel(_ image: UIImage, x: Int, y: Int) -> (red: UInt8, green: UInt8, blue: UInt8) { + let width = Int(image.size.width), height = Int(image.size.height) + var bytes = [UInt8](repeating: 0, count: width * height * 4) + bytes.withUnsafeMutableBytes { buffer in + let context = CGContext(data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(image.cgImage!, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + let offset = (y * width + x) * 4 + return (bytes[offset], bytes[offset + 1], bytes[offset + 2]) + } +} + +@MainActor +struct StoryTextInputTests { + @Test func staleSwiftUISnapshotsDoNotReplaceNativeRapidInputOrCaret() { + var model = "" + let input = StoryTextInput(text: Binding(get: { model }, set: { model = $0 }), onSticker: { _ in }, onError: { _ in }) + let coordinator = input.makeCoordinator() + let view = EditorTestTextView() + view.pretendFirstResponder = true + view.delegate = coordinator + for character in "A little more present" { + let staleSnapshot = model + view.insertText(String(character)) + coordinator.textViewDidChange(view) + coordinator.synchronize(view, modelText: staleSnapshot) + #expect(view.text == model) + #expect(view.selectedRange == NSRange(location: model.utf16.count, length: 0)) + } + #expect(view.text == "A little more present") + #expect(model == "A little more present") + } + + @Test func markedCompositionStaysNativeUntilCommit() { + var model = "Before " + let input = StoryTextInput(text: Binding(get: { model }, set: { model = $0 }), onSticker: { _ in }, onError: { _ in }) + let coordinator = input.makeCoordinator() + let view = EditorTestTextView() + view.text = "Before にほん" + view.selectedRange = NSRange(location: view.text.utf16.count, length: 0) + view.pretendMarkedText = true + coordinator.textViewDidChange(view) + coordinator.synchronize(view, modelText: model) + #expect(view.text == "Before にほん") + #expect(model == "Before ") + view.pretendMarkedText = false + view.text = "Before 日本" + view.selectedRange = NSRange(location: view.text.utf16.count, length: 0) + coordinator.textViewDidChange(view) + #expect(model == "Before 日本") + } + + @Test func saveReadsLatestNativeTextBeforeBindingEchoAndCommitsIME() { + var model = "Hello" + let handle = StoryTextInputHandle() + let input = StoryTextInput(text: Binding(get: { model }, set: { model = $0 }), handle: handle, onSticker: { _ in }, onError: { _ in }) + let coordinator = input.makeCoordinator() + let view = EditorTestTextView() + view.text = "Hello Presently" + view.pretendMarkedText = true + handle.textView = view + handle.coordinator = coordinator + #expect(handle.committedText() == "Hello Presently") + #expect(!view.pretendMarkedText) + #expect(model == "Hello Presently") + } + + @Test func externalChangesPreserveSelectionWhenNativeEditorIsInactive() { + let input = StoryTextInput(text: .constant(""), onSticker: { _ in }, onError: { _ in }) + let coordinator = input.makeCoordinator() + let view = EditorTestTextView() + view.text = "Original text" + view.selectedRange = NSRange(location: 3, length: 4) + coordinator.synchronize(view, modelText: "Replacement text") + #expect(view.text == "Replacement text") + #expect(view.selectedRange == NSRange(location: 3, length: 4)) + coordinator.synchronize(view, modelText: "Hi") + #expect(view.selectedRange == NSRange(location: 2, length: 0)) + } +} + +@MainActor +private final class EditorTestTextView: UITextView { + var pretendFirstResponder = false + var pretendMarkedText = false + override var isFirstResponder: Bool { pretendFirstResponder } + override var markedTextRange: UITextRange? { pretendMarkedText ? selectedTextRange : nil } + override func unmarkText() { pretendMarkedText = false; super.unmarkText() } +} diff --git a/docs/MVP_PLAN.md b/docs/MVP_PLAN.md index b1b7efe..6111761 100644 --- a/docs/MVP_PLAN.md +++ b/docs/MVP_PLAN.md @@ -13,7 +13,8 @@ validate browsing, engagement, group moments, or event discovery. 1. Launch Presently directly into a full-screen camera. 2. If signed out, connect an AT Protocol account through the system browser. 3. Capture one still photo. -4. Review, retake, and choose whether to save a copy to the device library. +4. Review, optionally add text or static stickers, retake, and choose whether + to save a copy of the finished photo to the device library. 5. Tap **Post story**. 6. Presently uploads one JPEG blob to the signed-in account's PDS. 7. Presently creates a `blue.flashes.story.post` record with a 24-hour expiry. @@ -35,13 +36,15 @@ deletion from every copy of the network. OAuth client metadata document. - JPEG output, under the schema's 10 MiB maximum. - Local pending/failed draft persistence and safe retry. +- Native text/emoji/static sticker editing, layer transforms, center guides, + delete/undo, and editable draft restoration. See [STORY_EDITOR.md](STORY_EDITOR.md). - Optional save to Photos/MediaStore. - Exact Flashes `blue.flashes.story.post` compatibility. ### Excluded - Feed, story viewer, replies, reactions, view counts, notifications. -- Video, multiple images, text overlays, facets, filters, editing. +- Video, multiple story images, animated stickers, subject cutouts, facets and filters. - Events, RSVP discovery, GPS, group moments, or a Presently lexicon. - A backend that stores user access or refresh tokens. - Claiming network deletion when the record's display window expires. From c8a6d13762ec85275578f134e72496b4289b1188 Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Mon, 14 Sep 2026 14:47:01 -0500 Subject: [PATCH 4/4] docs: record native editor acceptance evidence --- docs/STORY_EDITOR.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/STORY_EDITOR.md b/docs/STORY_EDITOR.md index e5f1fbf..3eb5450 100644 --- a/docs/STORY_EDITOR.md +++ b/docs/STORY_EDITOR.md @@ -59,3 +59,23 @@ Check that toolbars remain reachable with the keyboard and larger text sizes. Physical camera capture, device-specific image keyboards, photo-library saving, and authenticated publishing to a PDS require separate device acceptance. Simulator builds and local export tests do not establish that acceptance. + +### Implementation validation — 2026-09-14 + +- iOS: build and all 39 tests passed. Simulator checks covered native text + entry, emoji, Photos picker sticker import, drag, delete/undo, and draft + restoration after stop/relaunch. A text-input synchronization regression + found during these checks was fixed and covered by regression tests. +- Android: the isolated editor commit passed all 34 unit tests, debug build, + and lint. The working checkout also passed its 41 tests, including unrelated + existing local changes. Native instrumentation exercised text, emoji, image + import, undo, deletion, resize/rotation/nudges, and draft restoration after + force-stop/relaunch in separate processes. +- Web type checking/build and Go service tests/build passed. +- Physical-device camera and keyboard behavior, photo-library saving, and an + authenticated PDS publish remain release acceptance checks. + +For local editor QA without camera capture, Debug builds accept `--editor-demo` +on iOS and the boolean launch extra `presently.editor.sample` on Android. These +seed a synthetic photo only when no unfinished draft exists. They do not sign +in or publish a story.