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.
@@ -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 != "fetch") {
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
- if (file.lastModified() < cutoff) file.deleteRecursively()
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.344";
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
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.344";
378
+ var version = "0.0.346";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.344";
120
+ var version = "0.0.346";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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 CraftNativeFiles.newFile(named: "voice-feedback.m4a") }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.344",
3
+ "version": "0.0.346",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",