craft-native 0.0.344 → 0.0.346
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -2
- package/dist/android/templates/CraftNativeServices.kt.template +35 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +4 -0
- package/dist/ios/templates/CraftNativeFiles.swift +3 -0
- package/dist/ios/templates/CraftNativeScreen.swift +1 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +54 -1
- package/package.json +1 -1
|
@@ -16,6 +16,9 @@ import android.graphics.Bitmap
|
|
|
16
16
|
import android.graphics.BitmapFactory
|
|
17
17
|
import android.os.Build
|
|
18
18
|
import android.net.Uri
|
|
19
|
+
import android.net.ConnectivityManager
|
|
20
|
+
import android.net.Network
|
|
21
|
+
import android.net.NetworkCapabilities
|
|
19
22
|
import android.provider.MediaStore
|
|
20
23
|
import android.util.Base64
|
|
21
24
|
import androidx.core.content.ContextCompat
|
|
@@ -111,13 +114,38 @@ internal class CraftNativeCapabilities(
|
|
|
111
114
|
val videoLimit: Long = 0,
|
|
112
115
|
)
|
|
113
116
|
|
|
117
|
+
private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
118
|
+
private val networkWatch = object : ConnectivityManager.NetworkCallback() {
|
|
119
|
+
private fun changed() {
|
|
120
|
+
if (!closed.get()) CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftNetworkChange").put("detail", networkStatus()))
|
|
121
|
+
}
|
|
122
|
+
override fun onAvailable(network: Network) = changed()
|
|
123
|
+
override fun onLost(network: Network) = changed()
|
|
124
|
+
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = changed()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun networkStatus(): JSONObject {
|
|
128
|
+
val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
|
|
129
|
+
val connected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
|
130
|
+
val type = when {
|
|
131
|
+
!connected -> "none"
|
|
132
|
+
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
|
|
133
|
+
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular"
|
|
134
|
+
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "ethernet"
|
|
135
|
+
else -> "unknown"
|
|
136
|
+
}
|
|
137
|
+
return JSONObject().put("isConnected", connected).put("type", type)
|
|
138
|
+
}
|
|
139
|
+
|
|
114
140
|
init {
|
|
141
|
+
connectivity.registerDefaultNetworkCallback(networkWatch)
|
|
115
142
|
restoreNotifications()
|
|
116
143
|
sweepFiles()
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
fun names(): JSONArray = JSONArray().apply {
|
|
120
147
|
listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "files").forEach(::put)
|
|
148
|
+
put("network")
|
|
121
149
|
put("storageCompareAndSet")
|
|
122
150
|
put("storageSetIfAbsent")
|
|
123
151
|
put("crypto")
|
|
@@ -179,7 +207,9 @@ internal class CraftNativeCapabilities(
|
|
|
179
207
|
"DeepLinks" -> complete(requestToken, deepLinks(method), completion)
|
|
180
208
|
"Notifications" -> notifications(requestToken, method, args, completion)
|
|
181
209
|
"Network" -> {
|
|
182
|
-
if (method
|
|
210
|
+
if (method == "getStatus") {
|
|
211
|
+
complete(requestToken, success(networkStatus()), completion)
|
|
212
|
+
} else if (method != "fetch") {
|
|
183
213
|
complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Network method"), completion)
|
|
184
214
|
} else networkExecutor.execute {
|
|
185
215
|
complete(requestToken, fetch(requestToken, args), completion)
|
|
@@ -207,6 +237,7 @@ internal class CraftNativeCapabilities(
|
|
|
207
237
|
|
|
208
238
|
fun close() {
|
|
209
239
|
if (!closed.compareAndSet(false, true)) return
|
|
240
|
+
runCatching { connectivity.unregisterNetworkCallback(networkWatch) }
|
|
210
241
|
cancelled.clear()
|
|
211
242
|
if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
|
|
212
243
|
databaseExecutor.shutdownNow()
|
|
@@ -630,7 +661,10 @@ internal class CraftNativeCapabilities(
|
|
|
630
661
|
private fun sweepFiles() {
|
|
631
662
|
val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
|
|
632
663
|
fileRoot.listFiles()?.forEach { file ->
|
|
633
|
-
|
|
664
|
+
// Voice captures have a separate launch lifetime; sweep only
|
|
665
|
+
// generic per-file UUID folders here.
|
|
666
|
+
val owned = runCatching { UUID.fromString(file.name).toString().equals(file.name, ignoreCase = true) }.getOrDefault(false)
|
|
667
|
+
if (owned && file.lastModified() < cutoff) file.deleteRecursively()
|
|
634
668
|
}
|
|
635
669
|
}
|
|
636
670
|
|
|
@@ -85,6 +85,7 @@ internal class CraftNativeServices(
|
|
|
85
85
|
{{NATIVE_WALLET_FIELDS}}
|
|
86
86
|
|
|
87
87
|
init {
|
|
88
|
+
prepareVoiceFiles(context, config.optBoolean("enableAudioRecording"))
|
|
88
89
|
if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
|
|
89
90
|
// Native screens must also hear a recording started by the page.
|
|
90
91
|
// Keep this process sink independent from the page's listener.
|
|
@@ -629,7 +630,7 @@ internal class CraftNativeServices(
|
|
|
629
630
|
private var voiceFile: File? = null
|
|
630
631
|
private var pendingVoicePermission: ((Boolean) -> Unit)? = null
|
|
631
632
|
|
|
632
|
-
fun pauseVoice() { if (voiceRecorder != null) cancelVoice() }
|
|
633
|
+
fun pauseVoice() { if (voiceRecorder != null || voiceFile != null) cancelVoice() }
|
|
633
634
|
private fun cancelVoice() {
|
|
634
635
|
voiceOwner = null
|
|
635
636
|
val recorder = voiceRecorder
|
|
@@ -676,7 +677,7 @@ internal class CraftNativeServices(
|
|
|
676
677
|
if (voiceOwner != key) { done(failure("RECORDING_CANCELLED", "Recording was cancelled")); return@start }
|
|
677
678
|
if (!allowed) { cancelVoice(); done(failure("PERMISSION_DENIED", "Microphone permission was refused")); return@start }
|
|
678
679
|
try {
|
|
679
|
-
val folder = File(context.cacheDir, "craft-files/" + UUID.randomUUID()).apply { mkdirs() }
|
|
680
|
+
val folder = File(context.cacheDir, "craft-files/voice-recordings/" + UUID.randomUUID()).apply { mkdirs() }
|
|
680
681
|
val file = File(folder, "voice-feedback.m4a")
|
|
681
682
|
voiceFile = file
|
|
682
683
|
@Suppress("DEPRECATION")
|
|
@@ -816,6 +817,38 @@ internal class CraftNativeServices(
|
|
|
816
817
|
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
817
818
|
|
|
818
819
|
companion object {
|
|
820
|
+
private var voiceFilesPrepared = false
|
|
821
|
+
private var legacyVoiceFilesPrepared = false
|
|
822
|
+
|
|
823
|
+
@Synchronized
|
|
824
|
+
private fun prepareVoiceFiles(context: Context, includeLegacy: Boolean) {
|
|
825
|
+
val root = File(context.cacheDir, "craft-files")
|
|
826
|
+
if (!voiceFilesPrepared) {
|
|
827
|
+
discardAbandonedVoiceFiles(root, false)
|
|
828
|
+
voiceFilesPrepared = true
|
|
829
|
+
}
|
|
830
|
+
if (includeLegacy && !legacyVoiceFilesPrepared) {
|
|
831
|
+
discardAbandonedVoiceFiles(root)
|
|
832
|
+
legacyVoiceFilesPrepared = true
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
internal fun discardAbandonedVoiceFiles(root: File, includeLegacy: Boolean = true) {
|
|
837
|
+
val base = root.canonicalFile
|
|
838
|
+
val voice = File(base, "voice-recordings")
|
|
839
|
+
for (scope in if (includeLegacy) listOf(base, voice) else listOf(voice)) {
|
|
840
|
+
if (scope.canonicalFile != scope.absoluteFile) continue
|
|
841
|
+
for (folder in scope.listFiles().orEmpty()) {
|
|
842
|
+
val owned = runCatching { UUID.fromString(folder.name).toString().equals(folder.name, ignoreCase = true) }.getOrDefault(false)
|
|
843
|
+
if (!owned || folder.canonicalFile != folder.absoluteFile) continue
|
|
844
|
+
val file = File(folder, "voice-feedback.m4a")
|
|
845
|
+
if (!file.isFile || file.canonicalFile != file.absoluteFile) continue
|
|
846
|
+
file.delete()
|
|
847
|
+
if (folder.listFiles()?.isEmpty() == true) folder.delete()
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
819
852
|
const val PUSH_PERMISSION_REQUEST = 4207
|
|
820
853
|
const val LOCATION_PERMISSION_REQUEST = 4208
|
|
821
854
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3022,7 +3022,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3022
3022
|
`);
|
|
3023
3023
|
}
|
|
3024
3024
|
// package.json
|
|
3025
|
-
var version = "0.0.
|
|
3025
|
+
var version = "0.0.346";
|
|
3026
3026
|
|
|
3027
3027
|
// bin/cli.ts
|
|
3028
3028
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -9676,7 +9676,7 @@ final class CraftZigRuntime: NSObject {
|
|
|
9676
9676
|
enum CraftWebServices {
|
|
9677
9677
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
9678
9678
|
/// Coordinator events a native screen hears too.
|
|
9679
|
-
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
|
|
9679
|
+
static let forwardedEvents: Set<String> = ["craftNetworkChange", "craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
|
|
9680
9680
|
static let event = Notification.Name("craftWebServicesEvent")
|
|
9681
9681
|
|
|
9682
9682
|
static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
|
|
@@ -43,6 +43,7 @@ enum CraftNativeActions {
|
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
45
|
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
|
|
46
|
+
values.append("network")
|
|
46
47
|
values.append("nativePager")
|
|
47
48
|
values.append("storageCompareAndSet")
|
|
48
49
|
values.append("storageSetIfAbsent")
|
|
@@ -245,6 +246,9 @@ enum CraftNativeActions {
|
|
|
245
246
|
}
|
|
246
247
|
authenticate(requestToken: requestToken, args: args, completion: completion)
|
|
247
248
|
return
|
|
249
|
+
case ("Network", "getStatus"):
|
|
250
|
+
CraftWebServices.perform(action: "getNetworkStatus", body: [:]) { complete(requestToken, $0, completion) }
|
|
251
|
+
return
|
|
248
252
|
case ("Network", "fetch"):
|
|
249
253
|
fetch(requestToken: requestToken, args: args, completion: completion)
|
|
250
254
|
return
|
|
@@ -94,6 +94,9 @@ enum CraftNativeFiles {
|
|
|
94
94
|
let dayAgo = Date().addingTimeInterval(-86_400)
|
|
95
95
|
let folders = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey])) ?? []
|
|
96
96
|
for folder in folders {
|
|
97
|
+
// Only generic per-file folders: private voice captures have their
|
|
98
|
+
// own launch lifetime and must not be swept during a live recording.
|
|
99
|
+
guard UUID(uuidString: folder.lastPathComponent) != nil else { continue }
|
|
97
100
|
let created = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast
|
|
98
101
|
if created < dayAgo { try? fm.removeItem(at: folder) }
|
|
99
102
|
}
|
|
@@ -5804,6 +5804,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5804
5804
|
self.routeProps = routeProps
|
|
5805
5805
|
self.isAppScript = isAppScript
|
|
5806
5806
|
super.init(nibName: nil, bundle: nil)
|
|
5807
|
+
CraftVoiceRecording.prepareForLaunch(includeLegacy: config.enableAudioRecording)
|
|
5807
5808
|
}
|
|
5808
5809
|
|
|
5809
5810
|
required init?(coder: NSCoder) {
|
|
@@ -13,6 +13,54 @@ extension AVAudioRecorder: CraftVoiceRecorder {}
|
|
|
13
13
|
/// is starting, so a late permission/encoder answer cannot resurrect it.
|
|
14
14
|
final class CraftVoiceRecording {
|
|
15
15
|
static let shared = CraftVoiceRecording()
|
|
16
|
+
private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .utility)
|
|
17
|
+
private static let launchCleanup: Void = discardAbandonedRecordings(includeLegacy: false)
|
|
18
|
+
private static let legacyCleanup: Void = discardAbandonedRecordings()
|
|
19
|
+
private static let folderName = "voice-recordings"
|
|
20
|
+
|
|
21
|
+
/// Temporary voice files have a launch lifetime, unlike picked files and
|
|
22
|
+
/// downloads. Run once across all screens, without touching audio hardware.
|
|
23
|
+
static func prepareForLaunch(includeLegacy: Bool = true) {
|
|
24
|
+
_ = launchCleanup
|
|
25
|
+
if includeLegacy { _ = legacyCleanup }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func discardAbandonedRecordings(in root: URL = CraftNativeFiles.root, includeLegacy: Bool = true) {
|
|
29
|
+
let fm = FileManager.default
|
|
30
|
+
// Also migrate the recorder's earlier UUID folders at craft-files' root.
|
|
31
|
+
let voice = root.appendingPathComponent(folderName, isDirectory: true)
|
|
32
|
+
for scope in includeLegacy ? [root, voice] : [voice] {
|
|
33
|
+
guard (try? scope.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { continue }
|
|
34
|
+
let folders = (try? fm.contentsOfDirectory(at: scope, includingPropertiesForKeys: [.isSymbolicLinkKey])) ?? []
|
|
35
|
+
for folder in folders {
|
|
36
|
+
guard UUID(uuidString: folder.lastPathComponent) != nil,
|
|
37
|
+
(try? folder.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { continue }
|
|
38
|
+
let file = folder.appendingPathComponent("voice-feedback.m4a")
|
|
39
|
+
let values = try? file.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey])
|
|
40
|
+
guard values?.isRegularFile == true, values?.isSymbolicLink != true else { continue }
|
|
41
|
+
try? fm.removeItem(at: file)
|
|
42
|
+
if (try? fm.contentsOfDirectory(atPath: folder.path).isEmpty) == true { try? fm.removeItem(at: folder) }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static func newRecordingFile() throws -> URL {
|
|
48
|
+
prepareForLaunch()
|
|
49
|
+
let root = CraftNativeFiles.root.appendingPathComponent(folderName, isDirectory: true)
|
|
50
|
+
guard (try? root.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { throw CocoaError(.fileWriteNoPermission) }
|
|
51
|
+
let folder = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
52
|
+
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
53
|
+
let file = folder.appendingPathComponent("voice-feedback.m4a")
|
|
54
|
+
do {
|
|
55
|
+
// An owned placeholder lets cancellation erase the path even when
|
|
56
|
+
// AVFoundation has not finished constructing its recorder yet.
|
|
57
|
+
try Data().write(to: file)
|
|
58
|
+
return file
|
|
59
|
+
} catch {
|
|
60
|
+
try? FileManager.default.removeItem(at: folder)
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
16
64
|
private final class Session {
|
|
17
65
|
let key: String
|
|
18
66
|
var file: URL?
|
|
@@ -53,6 +101,7 @@ final class CraftVoiceRecording {
|
|
|
53
101
|
return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
|
|
54
102
|
},
|
|
55
103
|
deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
|
|
104
|
+
Self.prepareForLaunch()
|
|
56
105
|
self.audioQueue = audioQueue
|
|
57
106
|
self.startupTimeout = startupTimeout
|
|
58
107
|
self.permission = permission
|
|
@@ -75,6 +124,10 @@ final class CraftVoiceRecording {
|
|
|
75
124
|
current = nil
|
|
76
125
|
session.invalidate()
|
|
77
126
|
session.finish(.failure(error))
|
|
127
|
+
if let file = session.file {
|
|
128
|
+
// A stalled encoder must not hold deletion behind its audio queue.
|
|
129
|
+
Self.fileQueue.async { CraftNativeFiles.remove(file.path) }
|
|
130
|
+
}
|
|
78
131
|
audioQueue.async { self.erase(session) }
|
|
79
132
|
}
|
|
80
133
|
|
|
@@ -132,7 +185,7 @@ final class CraftVoiceRecording {
|
|
|
132
185
|
return
|
|
133
186
|
}
|
|
134
187
|
guard allowed else { self.clear(CraftNativeActionError(code: "PERMISSION_DENIED", message: "Microphone permission was refused")); return }
|
|
135
|
-
do { session.file = try
|
|
188
|
+
do { session.file = try Self.newRecordingFile() }
|
|
136
189
|
catch { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The recording file could not be created")); return }
|
|
137
190
|
guard let file = session.file else { return }
|
|
138
191
|
let deadline = DispatchWorkItem { [weak host = self] in
|