Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/native-intake-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@openmapx/extension-cli": patch
---

Rebuild the public CLI bundle after shared core native-intake contracts and bridge
version updates. Extension commands retain their existing behavior.
2 changes: 2 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const config: ExpoConfig = {
assetBundlePatterns: ["assets/**"],
ios: {
bundleIdentifier: mobile.appId,
...(mobile.appleTeamId && { appleTeamId: mobile.appleTeamId }),
// Phone is the supported first-release form factor; no tablet layout is
// claimed anywhere in the store metadata.
supportsTablet: false,
Expand Down Expand Up @@ -118,6 +119,7 @@ const config: ExpoConfig = {
// entitlement, the bundle-identifier URL scheme).
"./plugins/withOpenMapXNativeConfig",
"./plugins/withNavigationSurfaces",
"./plugins/withNativeIntake",
[
"expo-build-properties",
{
Expand Down
16 changes: 9 additions & 7 deletions apps/mobile/config/mapIntents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import config from "../app.config";

describe("Android map chooser registration", () => {
it("claims VIEW geo links with default and browsable categories, without website verification", () => {
expect(config.android?.intentFilters).toEqual([
{
action: "VIEW",
category: ["DEFAULT", "BROWSABLE"],
data: [{ scheme: "geo" }],
},
]);
expect(config.android?.intentFilters).toEqual(
expect.arrayContaining([
{
action: "VIEW",
category: ["DEFAULT", "BROWSABLE"],
data: [{ scheme: "geo" }],
},
]),
);
});
});
117 changes: 117 additions & 0 deletions apps/mobile/config/nativeIntakeConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { THEME_COLORS, THEME_CORNER_RADIUS } from "../../../packages/core/src/theme.ts";

export const TARGET = "OpenMapXShareExtension";
const TYPES = ["com.topografix.gpx", "public.geojson", "com.google.earth.kml"];
const MIMES = [
"application/gpx+xml",
"application/geo+json",
"application/vnd.google-earth.kml+xml",
];
export function intakeAndroidFilters(): Array<{
action: string;
category: string[];
data: Array<{ mimeType: string; scheme?: string }>;
}> {
const geo = MIMES.map((mimeType) => ({ mimeType }));
return [
{
action: "SEND",
category: ["DEFAULT"],
data: [...geo, { mimeType: "text/plain" }, { mimeType: "text/uri-list" }],
},
{ action: "SEND_MULTIPLE", category: ["DEFAULT"], data: geo },
{ action: "VIEW", category: ["DEFAULT"], data: geo.map((d) => ({ ...d, scheme: "content" })) },
];
}
export function intakeImportedTypes() {
return TYPES.map((UTTypeIdentifier, i) => ({
UTTypeIdentifier,
UTTypeDescription: ["GPX", "GeoJSON", "KML"][i],
UTTypeConformsTo: [i === 1 ? "public.json" : "public.xml"],
UTTypeTagSpecification: {
"public.filename-extension": [["gpx"], ["geojson"], ["kml"]][i],
"public.mime-type": [MIMES[i]],
},
}));
}
export function intakeDocumentTypes() {
return [
{
CFBundleTypeName: "Geographic file",
CFBundleTypeRole: "Viewer",
LSHandlerRank: "Alternate",
LSItemContentTypes: TYPES,
},
];
}
export function intakeShareInfo(appId: string) {
const geo = TYPES.map(
(type) => `ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "${type}"`,
).join(" OR ");
const text =
'((ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text") AND NOT ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url")';
const rule = `extensionItems.@count == 1 AND SUBQUERY(extensionItems, $item, $item.attachments.@count >= 1 AND $item.attachments.@count <= 5 AND (SUBQUERY($item.attachments, $attachment, (${geo})).@count == $item.attachments.@count OR (SUBQUERY($item.attachments, $attachment, (${geo})).@count == 0 AND SUBQUERY($item.attachments, $attachment, ${text}).@count >= 1))).@count == 1`;
return {
CFBundleDisplayName: "OpenMapX",
CFBundleExecutable: "$(EXECUTABLE_NAME)",
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)",
CFBundleName: "$(PRODUCT_NAME)",
CFBundlePackageType: "XPC!",
CFBundleShortVersionString: "$(MARKETING_VERSION)",
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)",
OpenMapXIntakeAppGroup: `group.${appId}.intake`,
OpenMapXTheme: THEME_COLORS,
OpenMapXCornerRadius: THEME_CORNER_RADIUS,
UTImportedTypeDeclarations: intakeImportedTypes(),
NSExtension: {
NSExtensionPointIdentifier: "com.apple.share-services",
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController",
NSExtensionAttributes: { NSExtensionActivationRule: rule },
},
};
}
export function shareBuildSettings(
host: Record<string, unknown>,
appId: string,
): Record<string, unknown> {
return {
PRODUCT_BUNDLE_IDENTIFIER: `"${appId}.ShareExtension"`,
PRODUCT_NAME: `"${TARGET}"`,
INFOPLIST_FILE: `"${TARGET}/Info.plist"`,
CODE_SIGN_ENTITLEMENTS: `"${TARGET}/ShareExtension.entitlements"`,
IPHONEOS_DEPLOYMENT_TARGET: "16.4",
CURRENT_PROJECT_VERSION: host.CURRENT_PROJECT_VERSION ?? "1",
MARKETING_VERSION: host.MARKETING_VERSION ?? "1.0.0",
...(host.DEVELOPMENT_TEAM ? { DEVELOPMENT_TEAM: host.DEVELOPMENT_TEAM } : {}),
CODE_SIGN_STYLE: "Automatic",
SWIFT_VERSION: "5.0",
APPLICATION_EXTENSION_API_ONLY: "YES",
SKIP_INSTALL: "YES",
TARGETED_DEVICE_FAMILY: '"1"',
LD_RUNPATH_SEARCH_PATHS:
'"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"',
GENERATE_INFOPLIST_FILE: "NO",
};
}

export const INTAKE_RECEIVER = "expo.modules.openmapxintake.IntakeReceiverActivity";
export function intakeReceiverDeclaration() {
return {
$: {
"android:name": INTAKE_RECEIVER,
"android:exported": "true" as const,
"android:noHistory": "true",
"android:excludeFromRecents": "true",
"android:taskAffinity": "",
"android:launchMode": "standard",
"android:theme": "@android:style/Theme.Translucent.NoTitleBar",
},
"intent-filter": intakeAndroidFilters().map((f) => ({
action: [{ $: { "android:name": `android.intent.action.${f.action}` } }],
category: f.category.map((c) => ({ $: { "android:name": `android.intent.category.${c}` } })),
data: f.data.map((d) => ({
$: { "android:mimeType": d.mimeType, ...(d.scheme ? { "android:scheme": d.scheme } : {}) },
})),
})),
};
}
6 changes: 6 additions & 0 deletions apps/mobile/modules/openmapx-intake/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(name: "IntakeInbox", platforms: [.macOS(.v13)], targets: [
.target(name: "IntakeInbox", path: "ios", exclude: ["Tests", "Share", "IntakeContainer.swift", "IntakeAppDelegateSubscriber.swift", "OpenMapXIntakeModule.swift", "OpenMapXIntake.podspec", "PrivacyInfo.xcprivacy"], sources: ["IntakePolicy.swift", "IntakeInbox.swift", "ShareSelection.swift", "IntakeExternalDocument.swift"]),
.testTarget(name: "IntakeInboxTests", dependencies: ["IntakeInbox"], path: "ios/Tests")
])
15 changes: 15 additions & 0 deletions apps/mobile/modules/openmapx-intake/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'org.openmapx.intake'
version = '1.0.0'
android {
namespace "expo.modules.openmapxintake"
defaultConfig { versionCode 1; versionName "1.0.0" }
testOptions { unitTests { returnDefaultValues = false; includeAndroidResources = true } }
}
dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package expo.modules.openmapxintake

import android.content.Intent
import android.net.Uri
import android.os.Bundle

object IncomingIntent {
fun shouldCapture(intent: Intent, savedState: Bundle?): Boolean {
if (savedState != null || intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return false
return when (intent.action) {
Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> true
Intent.ACTION_VIEW -> intent.data?.scheme == "content"
else -> false
}
}
@Suppress("DEPRECATION")
fun attachments(intent: Intent): List<Uri> {
val streams = when (intent.action) {
Intent.ACTION_VIEW -> listOfNotNull(intent.data)
Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM).orEmpty()
Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))
else -> emptyList()
}
// Rich text previews use ClipData for thumbnails. Only stream extras declare files.
if (intent.action == Intent.ACTION_SEND && streams.isEmpty()) return emptyList()
if (streams.size > 5) throw IntakeFailure("too-many")
val uris = streams.toMutableList()
if (streams.isNotEmpty() && intent.action == Intent.ACTION_SEND_MULTIPLE) intent.clipData?.let { clip ->
if (clip.itemCount > 5) throw IntakeFailure("too-many")
for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { if (it !in uris) uris += it }
}
if (uris.size > 5) throw IntakeFailure("too-many")
return uris
}
fun isForeignContent(uri: Uri, ownAuthorities: Set<String>, resolvedUid: Int?, ownUid: Int): Boolean =
uri.scheme == "content" && !uri.authority.isNullOrEmpty() && uri.authority !in ownAuthorities && resolvedUid != ownUid
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package expo.modules.openmapxintake

import android.util.Base64
import java.io.File
import java.io.InputStream
import java.io.RandomAccessFile
import java.util.UUID
import org.json.JSONObject

data class IntakeEntry(val id: String, val createdAtMs: Long, val kind: String, val name: String, val byteLength: Int, val format: String? = null, val errorCode: String? = null) {
fun map(): Map<String, Any> = mutableMapOf<String, Any>("id" to id, "createdAtMs" to createdAtMs, "kind" to kind, "name" to name, "byteLength" to byteLength).also {
if (format != null) it["format"] = format
if (errorCode != null) it["errorCode"] = errorCode
}
}
data class IntakeChunk(val id: String, val offset: Int, val data: String, val byteLength: Int, val eof: Boolean) {
fun map() = mapOf("id" to id, "offset" to offset, "data" to data, "byteLength" to byteLength, "eof" to eof)
}
/** Only generated UUIDs address files. All instances share one process-wide commit lock. */
class IntakeInbox(directory: File, private val now: () -> Long = { System.currentTimeMillis() }) {
private val root = directory.canonicalFile.also { it.mkdirs() }
companion object { private val lock = Any() }
private fun entryDir(id: String): File {
if (!IntakePolicy.ids.matches(id)) throw IntakeFailure("unreadable")
return File(root, id)
}
private fun entries(): List<IntakeEntry> {
val result = mutableListOf<IntakeEntry>()
for (dir in root.listFiles().orEmpty()) {
if (!IntakePolicy.ids.matches(dir.name) || !dir.isDirectory || dir.canonicalFile != dir.absoluteFile) continue
try {
val meta = File(dir, "metadata.json")
if (meta.length() > 4096 || meta.canonicalFile != meta.absoluteFile) throw IntakeFailure("invalid-data")
val obj = JSONObject(meta.readText())
val kind = obj.getString("kind")
val keys = setOf("id", "createdAtMs", "kind", "name", "byteLength") + when (kind) { "file" -> setOf("format"); "error" -> setOf("errorCode"); "text" -> emptySet(); else -> throw IntakeFailure("invalid-data") }
if (obj.keys().asSequence().toSet() != keys || obj.getString("id") != dir.name) throw IntakeFailure("invalid-data")
val created = obj.getLong("createdAtMs"); val size = obj.getInt("byteLength"); val name = obj.getString("name")
if (created < 0 || now() - created >= IntakePolicy.TTL_MS || name != IntakePolicy.name(name)) throw IntakeFailure("expired")
val format = if (kind == "file") obj.getString("format") else null
val code = if (kind == "error") obj.getString("errorCode") else null
if (kind == "error") {
if (size != 0 || code !in IntakePolicy.errors) throw IntakeFailure("invalid-data")
} else {
val data = File(dir, "data")
val limit = if (kind == "text") IntakePolicy.TEXT_BYTES else IntakePolicy.FILE_BYTES
if (size !in 1..limit || !data.isFile || data.length() != size.toLong() || data.canonicalFile != data.absoluteFile) throw IntakeFailure("invalid-data")
if (kind == "file" && (format != IntakePolicy.format(name))) throw IntakeFailure("invalid-data")
}
result += IntakeEntry(dir.name, created, kind, name, size, format, code)
} catch (_: Exception) { dir.deleteRecursively() }
}
val stage = File(root, "staging")
for (file in stage.listFiles().orEmpty()) {
val epoch = file.name.substringBefore('-').toLongOrNull() ?: continue
if (now() - epoch > IntakePolicy.COPY_TIMEOUT_MS + 60000) file.deleteRecursively()
}
return result.sortedWith(compareBy<IntakeEntry> { it.createdAtMs }.thenBy { it.id })
}
fun list(): List<IntakeEntry> = synchronized(lock) { entries() }
fun putText(text: String, name: String = "Shared location"): IntakeEntry {
val bytes = text.toByteArray(Charsets.UTF_8)
if (text.length > IntakePolicy.TEXT_LENGTH || bytes.size > IntakePolicy.TEXT_BYTES) throw IntakeFailure("too-large")
if (text.isBlank()) throw IntakeFailure("unsupported")
return copy("text", IntakePolicy.name(name), null, bytes.inputStream(), IntakePolicy.TEXT_BYTES) { false }
}
fun putFile(name: String, source: InputStream, cancelled: () -> Boolean = { false }): IntakeEntry {
val display = IntakePolicy.name(name)
return copy("file", display, IntakePolicy.format(display), source, IntakePolicy.FILE_BYTES, cancelled)
}
private fun copy(kind: String, name: String, format: String?, source: InputStream, limit: Int, cancelled: () -> Boolean): IntakeEntry {
val stage = File(File(root, "staging").also { it.mkdirs() }, "${now()}-${UUID.randomUUID()}")
stage.mkdirs()
var size = 0
try {
File(stage, "data").outputStream().use { output ->
source.use { input ->
val buffer = ByteArray(8192)
while (true) {
if (cancelled()) throw IntakeFailure("unreadable")
val count = input.read(buffer)
if (count < 0) break
if (count == 0) continue
size += count
if (size > limit) throw IntakeFailure("too-large")
output.write(buffer, 0, count)
}
}
output.fd.sync()
}
if (size == 0) throw IntakeFailure("invalid-data")
return synchronized(lock) {
if (cancelled()) throw IntakeFailure("unreadable")
val current = entries().filter { it.kind != "error" }
if (current.size >= IntakePolicy.CONTENT_ITEMS || current.sumOf { it.byteLength.toLong() } + size > IntakePolicy.INBOX_BYTES) throw IntakeFailure("inbox-full")
val entry = IntakeEntry(UUID.randomUUID().toString(), now(), kind, name, size, format)
File(stage, "metadata.json").writeText(JSONObject(entry.map()).toString())
if (!stage.renameTo(entryDir(entry.id))) throw IntakeFailure("unreadable")
entry
}
} finally { stage.deleteRecursively() }
}
fun recordError(code: String) = synchronized(lock) {
val errors = entries().filter { it.kind == "error" }
for (old in errors.take((errors.size - IntakePolicy.ERROR_ITEMS + 1).coerceAtLeast(0))) entryDir(old.id).deleteRecursively()
val entry = IntakeEntry(UUID.randomUUID().toString(), now(), "error", "OpenMapX", 0, errorCode = code.takeIf { it in IntakePolicy.errors } ?: "unreadable")
val dir = entryDir(entry.id).also { it.mkdirs() }
File(dir, "metadata.json").writeText(JSONObject(entry.map()).toString())
}
fun readChunk(id: String, offset: Int): IntakeChunk = synchronized(lock) {
if (offset < 0 || offset % IntakePolicy.CHUNK_BYTES != 0) throw IntakeFailure("unreadable")
val dir = entryDir(id)
val entry = entries().find { it.id == id && it.kind != "error" } ?: throw IntakeFailure("expired")
if (offset >= entry.byteLength) throw IntakeFailure("unreadable")
val bytes = ByteArray(minOf(IntakePolicy.CHUNK_BYTES, entry.byteLength - offset))
RandomAccessFile(File(dir, "data"), "r").use { it.seek(offset.toLong()); it.readFully(bytes) }
IntakeChunk(id, offset, Base64.encodeToString(bytes, Base64.NO_WRAP), bytes.size, offset + bytes.size == entry.byteLength)
}
fun complete(id: String) = synchronized(lock) { entryDir(id).deleteRecursively(); Unit }
}
Loading
Loading