@craft-native/android 0.0.225 → 0.0.227

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.225",
3
+ "version": "0.0.227",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -100,6 +100,7 @@ internal class CraftNativeImageCache(
100
100
  true
101
101
  }
102
102
  catch (error: Exception) { false }
103
+ catch (error: OutOfMemoryError) { false }
103
104
  finally { staging?.delete() }
104
105
  }
105
106
 
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
120
120
  done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
121
121
  }
122
122
  "usage" -> done(success(CraftNativeMedia.usage(context)))
123
+ "clear" -> done(success(CraftNativeMedia.clear(context)))
123
124
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
124
125
  }
125
126
  "Background" -> {
@@ -643,6 +643,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
643
643
  private var accessorySpec: JSONObject? = null
644
644
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
645
645
  private val tabIconText = mutableMapOf<String, String>()
646
+ private val tabTitles = mutableMapOf<String, String>()
647
+ private val tabAccessibilityLabels = mutableMapOf<String, String>()
648
+ private val tabIconTints = mutableMapOf<String, Int>()
649
+ private val tabIconPulses = mutableSetOf<String>()
646
650
  private val tabImageURLs = mutableMapOf<String, String>()
647
651
  private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
648
652
  private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
@@ -706,6 +710,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
706
710
  cell.gravity = Gravity.CENTER
707
711
  cell.background = rippleBackground()
708
712
  cell.contentDescription = tab.title
713
+ cell.tag = "craft-tab-${tab.id}"
709
714
  val icon = TextView(context)
710
715
  icon.text = CraftNativeSymbols.glyph(tab.symbol)
711
716
  icon.textSize = 22f
@@ -716,6 +721,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
716
721
  val label = TextView(context)
717
722
  label.text = tab.title
718
723
  label.textSize = 12f
724
+ label.fontFeatureSettings = "tnum"
719
725
  label.gravity = Gravity.CENTER
720
726
  label.isSingleLine = true
721
727
  cell.addView(icon, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
@@ -844,12 +850,25 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
844
850
  val id = item.optString("id")
845
851
  val index = tabs.indexOfFirst { it.id == id }
846
852
  if (index < 0) return
847
- val cell = tabBar.getChildAt(index)
848
- if (item.has("accessibilityLabel")) cell.contentDescription = item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() } ?: tabs[index].title
853
+ if (item.has("accessibilityLabel")) {
854
+ tabAccessibilityLabels.remove(id)
855
+ item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() }?.let { tabAccessibilityLabels[id] = it.take(160) }
856
+ }
849
857
  if (item.has("iconText")) {
850
858
  tabIconText.remove(id)
851
859
  item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
852
860
  }
861
+ if (item.has("title")) {
862
+ tabTitles.remove(id)
863
+ item.optString("title").takeUnless { item.isNull("title") || it.isBlank() }?.let { tabTitles[id] = it.take(40) }
864
+ }
865
+ if (item.has("iconTint")) {
866
+ tabIconTints.remove(id)
867
+ if (!item.isNull("iconTint")) runCatching { Color.parseColor(item.optString("iconTint")) }.getOrNull()?.let { tabIconTints[id] = it }
868
+ }
869
+ if (item.has("iconPulse")) {
870
+ if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
871
+ }
853
872
  if (item.has("image")) {
854
873
  val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
855
874
  if (address != tabImageURLs[id]) {
@@ -896,16 +915,41 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
896
915
  tabViews.forEachIndexed { index, (icon, label) ->
897
916
  tabBar.getChildAt(index).isSelected = index == selectedTab
898
917
  val color = if (index == selectedTab) accent else muted
899
- val number = tabIconText[tabs[index].id]
900
- icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
918
+ val id = tabs[index].id
919
+ val number = tabIconText[id]
920
+ val iconColor = tabIconTints[id] ?: if (number != null) Color.parseColor("#FF2D55") else color
921
+ icon.setTextColor(iconColor)
922
+ label.text = tabTitles[id] ?: tabs[index].title
923
+ tabBar.getChildAt(index).contentDescription = tabAccessibilityLabels[id] ?: label.text
901
924
  label.setTextColor(color)
902
- val vector = tabImages[tabs[index].id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, color) else null
925
+ val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, iconColor) else null
926
+ val pulse = tabIconPulses.contains(id) && number == null && tabImages[id] == null && hasWindowFocus() && android.animation.ValueAnimator.areAnimatorsEnabled()
927
+ if (pulse && icon.animation == null) {
928
+ icon.startAnimation(android.view.animation.AlphaAnimation(1f, 0.55f).apply {
929
+ duration = 700
930
+ repeatCount = android.view.animation.Animation.INFINITE
931
+ repeatMode = android.view.animation.Animation.REVERSE
932
+ })
933
+ } else if (!pulse) {
934
+ icon.clearAnimation()
935
+ icon.alpha = 1f
936
+ }
903
937
  icon.foreground = vector
904
938
  icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
905
939
  }
906
940
  navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
907
941
  }
908
942
 
943
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
944
+ super.onWindowFocusChanged(hasWindowFocus)
945
+ if (hasWindowFocus) refreshColors() else tabViews.forEach { (icon, _) -> icon.clearAnimation() }
946
+ }
947
+
948
+ override fun onDetachedFromWindow() {
949
+ tabViews.forEach { (icon, _) -> icon.clearAnimation() }
950
+ super.onDetachedFromWindow()
951
+ }
952
+
909
953
  private fun navButton(button: JSONObject): View {
910
954
  val id = button.optString("id")
911
955
  val title = button.optString("title")
@@ -1021,7 +1065,8 @@ internal object CraftNativeSymbols {
1021
1065
  * A native Video given an address with a copy here plays the copy.
1022
1066
  */
1023
1067
  internal object CraftNativeMedia {
1024
- private val inFlight = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
1068
+ private data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
1069
+ private val inFlight = HashMap<String, Flight>()
1025
1070
  private val executor = Executors.newFixedThreadPool(2)
1026
1071
  private val main = Handler(Looper.getMainLooper())
1027
1072
 
@@ -1054,12 +1099,12 @@ internal object CraftNativeMedia {
1054
1099
  if (!cellular && metered(context)) {
1055
1100
  return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
1056
1101
  }
1057
- synchronized(inFlight) {
1102
+ val flight = synchronized(inFlight) {
1058
1103
  inFlight[address]?.let { waiting ->
1059
- waiting.add(done)
1104
+ waiting.waiting.add(done)
1060
1105
  return
1061
1106
  }
1062
- inFlight[address] = mutableListOf(done)
1107
+ Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
1063
1108
  }
1064
1109
  executor.execute {
1065
1110
  val result = runCatching {
@@ -1073,7 +1118,7 @@ internal object CraftNativeMedia {
1073
1118
  val target = file(context, address)
1074
1119
  target.parentFile?.mkdirs()
1075
1120
  // Written aside and moved into place, so a half-written file is never played.
1076
- val staging = File(target.parentFile, "${target.name}.part")
1121
+ val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
1077
1122
  try {
1078
1123
  val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
1079
1124
  val expected = connection.contentLengthLong
@@ -1081,6 +1126,9 @@ internal object CraftNativeMedia {
1081
1126
  return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
1082
1127
  }
1083
1128
  synchronized(inFlight) {
1129
+ if (inFlight[address]?.id != flight.id) {
1130
+ return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
1131
+ }
1084
1132
  if (!staging.renameTo(target)) {
1085
1133
  return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
1086
1134
  }
@@ -1098,7 +1146,7 @@ internal object CraftNativeMedia {
1098
1146
  }
1099
1147
  CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
1100
1148
  }
1101
- val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
1149
+ val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
1102
1150
  main.post { waiting.forEach { it(result) } }
1103
1151
  }
1104
1152
  }
@@ -1115,12 +1163,31 @@ internal object CraftNativeMedia {
1115
1163
  var removed = 0
1116
1164
  synchronized(inFlight) {
1117
1165
  // A concurrent keep owns its target and staging file until it finishes.
1118
- val active = inFlight.keys.map { file(context, it).name }.toSet()
1166
+ val active = inFlight.flatMap { (address, flight) ->
1167
+ val name = file(context, address).name
1168
+ listOf(name, "$name.${flight.id}.part")
1169
+ }.toSet()
1170
+ root(context).listFiles()?.forEach { file ->
1171
+ if (file.name !in active && file.name !in wanted && file.delete()) removed++
1172
+ }
1173
+ }
1174
+ return removed
1175
+ }
1176
+
1177
+ /** Invalidates old transfers before deleting files; a later keep starts afresh. */
1178
+ fun clear(context: Context): Int {
1179
+ var removed = 0
1180
+ var failure: java.io.IOException? = null
1181
+ val waiting = synchronized(inFlight) {
1182
+ val callbacks = inFlight.values.flatMap { it.waiting }
1183
+ inFlight.clear()
1119
1184
  root(context).listFiles()?.forEach { file ->
1120
- val target = file.name.removeSuffix(".part")
1121
- if (target !in active && file.name !in wanted && file.delete()) removed++
1185
+ if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1122
1186
  }
1187
+ callbacks
1123
1188
  }
1189
+ main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1190
+ failure?.let { throw it }
1124
1191
  return removed
1125
1192
  }
1126
1193
 
@@ -4091,6 +4091,7 @@ class MainActivity : AppCompatActivity() {
4091
4091
  show(target)
4092
4092
  } else {
4093
4093
  host.removeView(target.view)
4094
+ target.view.translationX = 0f
4094
4095
  current.view.translationX = 0f
4095
4096
  chrome?.selectTab(currentTab)
4096
4097
  tabSwipeTarget = null
@@ -4504,6 +4505,11 @@ class MainActivity : AppCompatActivity() {
4504
4505
  * screen shown before hears true (its `navigation.onAppear`).
4505
4506
  */
4506
4507
  private fun show(screen: Screen) {
4508
+ // A retained tab may have been the offscreen preview of a cancelled
4509
+ // swipe, or the outgoing view of a completed swipe. Its animator can
4510
+ // finish after the peer animator, so clear that ownership on display.
4511
+ screen.view.animate().withEndAction(null).setListener(null).cancel()
4512
+ screen.view.translationX = 0f
4507
4513
  // A navigation breadcrumb: the trail a crash report carries.
4508
4514
  if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
4509
4515
  val bars = chrome