Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,30 @@ Shortcuts. Load the Library and Clippings online, turn on airplane mode, close t
app and run each action; check the returned fields and unchanged Cached At. Sign
out and confirm actions no longer return the previous account's data. Sign into a
different account and verify a previously selected book cannot resolve to it.

### Importing files on mobile

Share or open books (`.epub`, `.acsm`, `.md`, `.txt`), fonts (`.ttf`, `.otf`,
`.cpfont`), and sleep-screen images (`.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`,
`.bmp`) in CrossPoint Sync to queue them in **Send to your reader**. Imports are copied into app-owned storage and
survive tab changes, restarts, and an unavailable reader. Tap Send when ready;
a successful send deletes its queued copy. Failed sends stay queued, and Remove
deletes only the app's copy. Files with identical names remain separate entries.
Book file-picker imports use the same persistent queue. Shared font styles from
one family are sent together through the existing font APIs; duplicate copies
of one style remain separate. Raw bold/italic uploads do not replace regular.
Converting a family to `.cpfont` requires its regular style. Shared images open
in the sleep-screen editor for sizing/dithering and BMP conversion before send;
editing or saving a preview leaves the original queued. Mixed batches show
counts on each Send tab, and imports also work in guest mode.

Android accepts Share (including multiple files) and Open intents. On iOS,
**Open in CrossPoint Sync** opens the app directly. The **CrossPoint Sync** Share
extension saves the files and confirms the result; open the app afterward to
send them. iOS does not provide a supported API for this Share extension to
launch its containing app. The extension uses the existing App Group
`group.com.crosspointreader.sync` and the new automatically signed bundle
identifier `com.crosspointreader.sync.share`.

See [mobile import checks](../docs/mobile-file-imports-testing.md) for native
build and device verification.
18 changes: 18 additions & 0 deletions app/scripts/import-tests/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

// Exercise the actual extension activation predicate, rather than inspecting
// its text: a CPFONT-only provider must activate without public.file-url.
let path = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "src-tauri/gen/apple/CrossPointShare/Info.plist"
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let info = try PropertyListSerialization.propertyList(from: data, format: nil) as! [String: Any]
let ext = info["NSExtension"] as! [String: Any]
let attrs = ext["NSExtensionAttributes"] as! [String: Any]
let rule = NSPredicate(format: attrs["NSExtensionActivationRule"] as! String)
for type in ["com.crosspointreader.cpfont", "public.png", "public.jpeg", "public.truetype-ttf-font", "public.opentype-font"] {
let input: [String: Any] = ["extensionItems": [["attachments": [["registeredTypeIdentifiers": [type]]]]]]
if !rule.evaluate(with: input) {
fputs("Share extension must accept \(type)\n", stderr)
exit(1)
}
}
print("Share activation checks passed")
1 change: 1 addition & 0 deletions app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ tauri-plugin-haptics = "2"
# UIKit-managed iPhone/Duo navigation from our fork, pinned with the JS package.
tauri-plugin-system-components = { git = "https://github.com/itsthisjustin/tauri-plugin-system-components", rev = "348476a5bd4ff19c95a944d9a5d93bf403d4e07b" }

[target.'cfg(target_os = "ios")'.dependencies]
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
block2 = "0.6"
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = ["NSData", "NSFileManager", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", features = ["NSData", "NSFileManager", "NSString", "NSURL", "NSFileCoordinator", "NSError", "block2"] }
[target.'cfg(target_os = "ios")'.dependencies]
objc2-ui-kit = { version = "0.3", default-features = false, features = ["std", "UIControl", "UIImage", "UIImagePickerController", "UIRefreshControl", "UIResponder", "UIView"] }

[dev-dependencies]
Expand Down
49 changes: 49 additions & 0 deletions app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,55 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="crosspointsync" android:host="widget" />
</intent-filter>
<!-- Incoming documents are copied to ImportQueue before URI grants expire. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/vnd.adobe.adept+xml" />
<data android:mimeType="application/acsm" />
<data android:mimeType="text/markdown" />
<data android:mimeType="text/x-markdown" />
<data android:mimeType="text/plain" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="font/ttf" />
<data android:mimeType="font/otf" />
<data android:mimeType="application/x-font-ttf" />
<data android:mimeType="application/x-font-opentype" />
<data android:mimeType="application/vnd.crosspoint.cpfont" />
<data android:mimeType="image/png" />
<data android:mimeType="image/jpeg" />
<data android:mimeType="image/webp" />
<data android:mimeType="image/gif" />
<data android:mimeType="image/bmp" />
<data android:mimeType="image/x-ms-bmp" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/vnd.adobe.adept+xml" />
<data android:mimeType="application/acsm" />
<data android:mimeType="text/markdown" />
<data android:mimeType="text/x-markdown" />
<data android:mimeType="text/plain" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="font/ttf" />
<data android:mimeType="font/otf" />
<data android:mimeType="application/x-font-ttf" />
<data android:mimeType="application/x-font-opentype" />
<data android:mimeType="application/vnd.crosspoint.cpfont" />
<data android:mimeType="image/png" />
<data android:mimeType="image/jpeg" />
<data android:mimeType="image/webp" />
<data android:mimeType="image/gif" />
<data android:mimeType="image/bmp" />
<data android:mimeType="image/x-ms-bmp" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter android:autoVerify="true" >
<action android:name="android.intent.action.VIEW" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.crosspointreader.sync

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import java.io.File

/** Same atomic directory format as Rust import_store and iOS ImportStore. */
internal object IncomingFiles {
@Suppress("DEPRECATION")
fun uris(intent: Intent): List<Uri> {
val streams: List<Uri> = when (intent.action) {
Intent.ACTION_VIEW -> listOfNotNull(intent.data)
Intent.ACTION_SEND -> listOfNotNull(if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) else intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))
Intent.ACTION_SEND_MULTIPLE -> (if (Build.VERSION.SDK_INT >= 33) intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) else intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)) ?: emptyList()
else -> return emptyList()
}
val clips = intent.clipData?.let { clip -> (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } } ?: emptyList()
return (streams + clips).filter { it.scheme == "content" || it.scheme == "file" }.distinct()
}

fun copy(context: Context, uri: Uri, id: String) {
require(id.matches(Regex("[a-zA-Z0-9-]+")))
// Persist completion separately from the queue: rotating or restoring the
// activity must not resurrect a file the user already sent or removed.
val delivered = context.getSharedPreferences("import-deliveries", Context.MODE_PRIVATE)
if (delivered.getBoolean(id, false)) return
val root = File(context.applicationInfo.dataDir, "ImportQueue").apply { check(mkdirs() || isDirectory) }
val destination = File(root, id)
// A prior process may have died after publish but before recording delivery.
if (destination.isDirectory) {
check(delivered.edit().putBoolean(id, true).commit()) { "Couldn't record imported file" }
return
}
val resolver = context.contentResolver
val displayName = if (uri.scheme == "content") {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
}
} else null
val name = displayName ?: uri.lastPathSegment ?: throw IllegalArgumentException("Missing file name")
require(validName(name)) { "Choose books, TTF/OTF/CPFONT fonts, or PNG/JPG/WebP/GIF/BMP sleep screens: $name" }
// Tauri PathPlugin.getDataDir resolves applicationInfo.dataDir, not filesDir.
val staging = File(root, ".$id")
staging.deleteRecursively() // Retry an incomplete copy after process death.
check(staging.mkdir()) { "Couldn't create import folder" }
try {
resolver.openInputStream(uri)?.use { input ->
File(staging, name).outputStream().use { output -> input.copyTo(output) }
} ?: throw IllegalArgumentException("Couldn't read $name")
check(staging.renameTo(destination)) { "Couldn't save $name" }
check(delivered.edit().putBoolean(id, true).commit()) { "Couldn't record imported file" }
} catch (error: Exception) {
staging.deleteRecursively()
throw error
}
}

private fun validName(name: String): Boolean =
name.isNotEmpty() && !name.startsWith('.') && name.none { it == '/' || it == '\\' || it.isISOControl() } &&
name.substringAfterLast('.', "").lowercase() in setOf("epub", "acsm", "md", "txt", "ttf", "otf", "cpfont", "png", "jpg", "jpeg", "webp", "gif", "bmp")
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import java.io.File

class MainActivity : TauriActivity() {
companion object {
private val importExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
private const val DELIVERY_STATE = "crosspoint-import-delivery"
private const val PAPER = 0xFFFAF8F3.toInt()
private const val PAPER_DARK = 0xFF121110.toInt()
}
Expand All @@ -39,6 +41,8 @@ class MainActivity : TauriActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
importDelivery = savedInstanceState?.getString(DELIVERY_STATE) ?: java.util.UUID.randomUUID().toString()
receiveFiles(intent, importDelivery)
val content = findViewById<View>(android.R.id.content)
val deadline = SystemClock.uptimeMillis() + 3000
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
Expand All @@ -58,6 +62,39 @@ class MainActivity : TauriActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
importDelivery = java.util.UUID.randomUUID().toString()
receiveFiles(intent, importDelivery)
}

private var importDelivery = java.util.UUID.randomUUID().toString()

override fun onSaveInstanceState(outState: Bundle) {
outState.putString(DELIVERY_STATE, importDelivery)
super.onSaveInstanceState(outState)
}

private fun receiveFiles(incoming: Intent?, delivery: String) {
if (incoming == null) return
val uris = IncomingFiles.uris(incoming)
if (uris.isEmpty()) return
// Resolve and copy content:// streams while the activity holds the sender's
// temporary URI grant. No external-storage permission or network fetch.
importExecutor.execute {
val failures = mutableListOf<String>()
for ((index, uri) in uris.withIndex()) {
try { IncomingFiles.copy(applicationContext, uri, "$delivery-$index") }
catch (error: Exception) { failures.add(error.message ?: "Couldn't import file") }
}
runOnUiThread {
webView?.evaluateJavascript("dispatchEvent(new Event('crosspoint-imports-changed'))", null)
if (failures.isNotEmpty()) android.widget.Toast.makeText(this, failures.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
}
}
}

override fun onDestroy() {
multicastLock?.release()
multicastLock = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.crosspointreader.sync</string>
</array>
</dict>
</plist>
105 changes: 105 additions & 0 deletions app/src-tauri/gen/apple/CrossPointShare/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossPoint Sync</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>0.1.2</string>
<key>CFBundleVersion</key>
<string>28</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY(extensionItems, $item, SUBQUERY($item.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.idpf.epub-container" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.adept.acsm" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.crosspointreader.cpfont" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.font" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image").@count &gt; 0).@count &gt; 0</string>
</dict>
</dict>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.idpf.epub-container</string>
<key>UTTypeDescription</key>
<string>EPUB book</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>epub</string>
<key>public.mime-type</key>
<string>application/epub+zip</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.adept.acsm</string>
<key>UTTypeDescription</key>
<string>Adobe Content Server Message</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>acsm</string>
<key>public.mime-type</key>
<string>application/vnd.adobe.adept+xml</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>net.daringfireball.markdown</string>
<key>UTTypeDescription</key>
<string>Markdown document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.plain-text</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>md</string>
<key>public.mime-type</key>
<string>text/markdown</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.crosspointreader.cpfont</string>
<key>UTTypeDescription</key>
<string>CrossPoint prebuilt font</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>cpfont</string>
<key>public.mime-type</key>
<string>application/vnd.crosspoint.cpfont</string>
</dict>
</dict>
</array>
</dict>
</plist>
Loading
Loading