Merge branch 'audio/android-mic-mute' into audio/mic-latency-echo

This commit is contained in:
2026-07-31 23:14:09 +02:00
7 changed files with 296 additions and 8 deletions
@@ -28,12 +28,20 @@ import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -44,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -107,6 +116,31 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// All three touch points run on the main thread; a plain list is race-free.
val micEffects = remember { mutableListOf<AudioEffect>() }
// In-stream mic mute. Per SESSION and never persisted (no setting backs it): a new stream
// always starts unmuted. The authoritative flag lives on the native handle, which is why a mute
// survives the mic stop/start a surface recreate performs — this state is the UI's mirror of
// it, and survives the same recreate because the composition outlives the surface.
var micMuted by remember(handle) { mutableStateOf(false) }
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
// rather than one that lies about a mic being heard.
var micRunning by remember(handle) { mutableStateOf(false) }
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
var micHint by remember { mutableStateOf<String?>(null) }
LaunchedEffect(micHint) {
if (micHint != null) {
delay(1600)
micHint = null
}
}
// The one place mute is toggled — Compose state + the native flag, always together.
val setMicMuted = { muted: Boolean ->
micMuted = muted
NativeBridge.nativeSetMicMuted(handle, muted)
}
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
@@ -297,6 +331,16 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
// to mute, and claiming otherwise would be the lie the control exists to avoid).
router.onMicChord = {
if (micRunning) {
val next = !micMuted
setMicMuted(next)
micHint = if (next) "Microphone muted" else "Microphone live"
}
}
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
@@ -493,6 +537,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.onMicChord = null // same: no mute toggle on buttons released during teardown
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
@@ -626,6 +671,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
if (initialSettings.echoCancel) {
attachMicEffects(sessionId, micEffects)
}
// Did a capture actually open? That — not the setting — is what
// puts the mute control on screen. A restart after a surface
// recreate comes back already muted if the user muted: the flag
// lives on the session handle, so nothing has to be re-applied.
micRunning = NativeBridge.nativeMicActive(handle)
}
}
@@ -639,6 +689,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
if (!closed.get()) {
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
// No capture, no control — but the MUTE state is deliberately left
// standing (native keeps it on the handle), so the restart in
// surfaceCreated brings the user's choice back with it.
micRunning = false
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
}
@@ -713,6 +767,20 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
},
)
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
// costs the stream that small corner of touch area, which is why it exists only while a
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
// control there, and a focusable button would fight the game for the D-pad.
if (micRunning && (micMuted || !isTv)) {
MicMuteControl(
muted = micMuted,
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
}
}
@@ -742,6 +810,63 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
effects.clear()
}
/**
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
* readable at couch distance and impossible to mistake for the stream's own picture.
*
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
* caller's decision — it means a capture is genuinely running.
*/
@Composable
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(10.dp)
Row(
modifier = modifier
.clip(shape)
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
// Spoken state first, then the action — a talkback user needs to know they are muted
// before they need to know how to stop being muted.
contentDescription = if (muted) {
"Microphone muted. Activate to unmute."
} else {
"Microphone live. Activate to mute."
},
tint = Color.White,
modifier = Modifier.size(20.dp),
)
if (muted) {
Spacer(Modifier.width(6.dp))
Text("Muted", color = Color.White, fontSize = 14.sp)
}
}
}
/**
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
* in-stream cues; the caller clears it after a beat.
*/
@Composable
private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
Text(
text,
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
@@ -65,6 +65,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
var onExitArmed: ((armed: Boolean) -> Unit)? = null
/**
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
* chord adds a meaning to them rather than swallowing them, exactly as the exit chord does.
*/
var onMicChord: (() -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null
@@ -108,14 +118,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/**
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
// that leaves the whole chord held. Any other button pressed while Select + Y are down
// fails the middle test, so the toggle happens once per chord, not once per press.
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
onMicChord?.invoke()
}
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
@@ -351,6 +370,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
const val EXIT_HOLD_MS = 1000L
/**
* Mic-mute chord: Select + Y. Y is deliberately NOT one of [EXIT_CHORD]'s buttons, so no
* way of reaching the exit chord can pass through this one on the way (and vice versa) —
* and Select is a menu button rather than a twitch action, which makes the pair unlikely
* to occur inside real play.
*/
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
}
@@ -300,9 +300,41 @@ object NativeBridge {
*/
external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int
/** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */
/**
* Stop + join the mic thread and close the AAudio input stream. No-op on `0`. Leaves the
* session's mute state ([nativeSetMicMuted]) alone — a surface recreate stops and restarts the
* mic, and a user who muted must stay muted through it.
*/
external fun nativeStopMic(handle: Long)
/**
* Mute/unmute the mic uplink mid-stream. Muting does NOT stop the capture: the AAudio input
* stream, the input preset it settled on and its primed buffers stay as they are, and the
* encode loop drops each 10 ms frame instead of encoding + sending it — so room audio is never
* encoded and nothing goes on the wire, while a toggle costs an atomic store and takes effect
* on the next 10 ms boundary (a stop/start would re-run the preset fallback ladder and re-prime
* buffers every time).
*
* Sticky for the SESSION — the flag lives on the handle, not on the capture — so the mic
* restart a surface recreate performs comes back muted, with no window for an unmuted frame to
* escape; a fresh session always starts unmuted. Nothing here is persisted. No-op on `0`.
* Cheap (one atomic store); UI-safe.
*
* One honest consequence of keeping the stream open: the platform's own recording indicator
* stays lit while muted, because the mic really is still open. What stops is the encode and the
* send — no captured audio leaves the process.
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
* on the user's setting: a device that refused every AAudio input rung (or a missing
* RECORD_AUDIO grant) then shows no control instead of a lie about a mic being heard. `false`
* on a `0` handle. Cheap; UI-safe.
*/
external fun nativeMicActive(handle: Long): Boolean
// ---- Input: Kotlin captures, Rust forwards to the host (send_input) ----
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
+40 -4
View File
@@ -9,6 +9,10 @@
//! the host decodes any Opus frame ≤ 120 ms with its stereo decoder (mono packets upmix), so this
//! needs no protocol change; speech gains nothing from stereo, and the shorter frame shaves a
//! buffering interval off the uplink.
//!
//! **Mute** is a flag the encode loop reads per 10 ms frame, never a stream teardown: the AAudio
//! input stream, the input-preset ladder it settled on and its primed buffers all survive a
//! mute/unmute untouched, so toggling costs an atomic load and nothing else.
use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode,
@@ -63,7 +67,16 @@ impl MicCapture {
/// preset deliberately bypasses them, which is why the host used to hear its own stream back
/// from a speaker-playing phone) — and allocates an audio session id for Kotlin's Java-effect
/// backstop. `None` on failure (the caller leaves the rest of the session streaming).
pub fn start(client: Arc<NativeClient>, echo_cancel: bool) -> Option<MicCapture> {
///
/// `muted` is the SESSION's live mic-mute flag (owned by `SessionHandle`, not by this capture),
/// honoured per frame by [`encode_loop`]. Sharing it rather than owning it is what makes mute
/// survive the mic stop/start a surface recreate performs — and means a capture started while
/// muted never encodes its first frame, so there is no window for one to escape.
pub fn start(
client: Arc<NativeClient>,
echo_cancel: bool,
muted: Arc<AtomicBool>,
) -> Option<MicCapture> {
let captured = Arc::new(AtomicU64::new(0));
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
// throttled from the encode worker.
@@ -211,7 +224,7 @@ impl MicCapture {
let sd = shutdown.clone();
let join = std::thread::Builder::new()
.name("pf-mic".into())
.spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped))
.spawn(move || encode_loop(client, rx, free_tx, sd, muted, captured, dropped))
.ok();
Some(MicCapture {
@@ -241,11 +254,16 @@ impl Drop for MicCapture {
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 10 ms mono frames → `send_mic`.
/// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across
/// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned).
///
/// While `muted` is set a formed frame is dropped instead of encoded (see the frame loop) — the
/// capture side keeps running exactly as it does unmuted, so nothing about the stream, its ring or
/// its backlog behaviour changes across a toggle.
fn encode_loop(
client: Arc<NativeClient>,
rx: Receiver<Vec<f32>>,
free_tx: SyncSender<Vec<f32>>,
shutdown: Arc<AtomicBool>,
muted: Arc<AtomicBool>,
captured: Arc<AtomicU64>,
dropped: Arc<AtomicU64>,
) {
@@ -279,6 +297,7 @@ fn encode_loop(
let mut seq: u32 = 0;
let mut sent: u64 = 0;
let mut stale: u64 = 0; // frames shed by the backlog self-heal (see BACKLOG_MAX_FRAMES)
let mut muted_frames: u64 = 0; // frames dropped unencoded because the user muted
let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence
while !shutdown.load(Ordering::Relaxed) {
@@ -308,6 +327,22 @@ fn encode_loop(
stale += (excess / frame) as u64;
}
while ring.len() >= frame {
// Muted: drop the frame at the last point before it would become an Opus packet —
// room audio is never encoded and nothing goes on the wire. `seq` still advances,
// because it numbers the captured 10 ms TIMELINE rather than the datagrams: the gap
// the host then sees is exactly the audio that never came, which its de-jitter reads
// as at most a few concealment frames before the pump's 600 ms stale-gap flush resets
// the chain outright — the right reading of a mute. (Encoding silence instead would
// keep a pointless uplink and a host-side ring alive for the whole mute.) `peak` is
// the loudest sample the UPLINK carried since the last log, so a dropped frame resets
// rather than raises it.
if muted.load(Ordering::Relaxed) {
ring.drain(..frame);
seq = seq.wrapping_add(1);
muted_frames += 1;
peak = 0.0;
continue;
}
for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) {
*dst = src;
}
@@ -326,7 +361,7 @@ fn encode_loop(
if sent % 500 == 0 {
log::info!(
"mic: sent={sent} captured_frames={} dropped_chunks={} \
stale_frames={stale} peak={peak:.3}",
stale_frames={stale} muted_frames={muted_frames} peak={peak:.3}",
captured.load(Ordering::Relaxed),
dropped.load(Ordering::Relaxed),
);
@@ -338,7 +373,8 @@ fn encode_loop(
}
}
log::info!(
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale})",
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale} \
muted_frames={muted_frames})",
captured.load(Ordering::Relaxed),
dropped.load(Ordering::Relaxed),
);
@@ -250,6 +250,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
Box::into_raw(Box::new(handle)) as jlong
}
@@ -61,6 +61,13 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
/// recreate, and a mute the user set must come back with it — with no window in which the
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
/// starts unmuted.
pub mic_muted: Arc<AtomicBool>,
}
struct VideoThread {
+61 -2
View File
@@ -415,7 +415,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
if let Some(m) = guard.as_ref() {
return m.session_id(); // already capturing — same stream, same session
}
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0) {
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
Some(m) => {
let session_id = m.session_id();
*guard = Some(m);
@@ -429,7 +431,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
}
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
/// stream (without closing the session). No-op on `0`.
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
@@ -445,3 +448,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply
/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset
/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different
/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one
/// relaxed load per frame there, and takes effect on the very next 10 ms boundary.
///
/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a
/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a
/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic
/// store, so it links on the host build too.
///
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
/// no captured audio leaves the process.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_this: JObject,
handle: jlong,
muted: jboolean,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.mic_muted
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
}
})
}
/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only
/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream
/// mute control is offered on this evidence rather than on the user's setting, so a device that
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.mic.lock().unwrap().is_some())
})
}