diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4dffd99..a96c0fe 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.elseif.yrxtals" minSdk = 28 targetSdk = 35 - versionCode = 1 - versionName = "0.1.0" + versionCode = 3 + versionName = "1.0.2" ndk { abiFilters += "arm64-v8a" } diff --git a/ios/src/CaptureSession.swift b/ios/src/CaptureSession.swift index c6d8919..7ef67f4 100644 --- a/ios/src/CaptureSession.swift +++ b/ios/src/CaptureSession.swift @@ -2,6 +2,7 @@ import AVFoundation import UIKit /// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline. +/// stash-and-restart lifecycle: isCapturing remembers user intent; engine is fully torn down on backgrounding and rebuilt from scratch on every return to active state. class CaptureSession { private var engine: AVAudioEngine? @@ -11,24 +12,21 @@ class CaptureSession { /// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime. var viewportHandleProvider: (() -> OpaquePointer?)? - /// configures AVAudioSession for low-latency mic capture, installs a tap, and starts the engine. + /// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers. func start() throws { if isCapturing { return } - - try configureSession() - try startEngine() - registerObservers() isCapturing = true + registerObservers() + try fullStartFromScratch() print("[YrXtals] CaptureSession started") } - /// removes the tap, stops the engine, and deactivates the audio session. + /// clears the capture-on intent and tears down the engine + session. func stop() { if !isCapturing { return } - unregisterObservers() - teardownEngine() isCapturing = false - try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + unregisterObservers() + teardownEngineAndSession() print("[YrXtals] CaptureSession stopped") } @@ -63,6 +61,13 @@ class CaptureSession { self.engine = engine } + /// fully tears down anything that might be alive, then runs the configure + tap + start sequence from scratch. + private func fullStartFromScratch() throws { + teardownEngine() + try configureSession() + try startEngine() + } + /// removes the tap and stops the AVAudioEngine. safe to call repeatedly. private func teardownEngine() { engine?.inputNode.removeTap(onBus: 0) @@ -70,21 +75,29 @@ class CaptureSession { engine = nil } - /// re-establishes the engine after an interruption or a return to the foreground. keeps the session active. - private func bounceEngine() { + /// tears down the engine and deactivates the AVAudioSession. + private func teardownEngineAndSession() { teardownEngine() - do { - try AVAudioSession.sharedInstance().setActive(true, options: []) - try startEngine() - print("[YrXtals] CaptureSession bounced") - } catch { - print("[YrXtals] CaptureSession bounce failed: \(error)") - } + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } - /// listens for interruption, foreground activation, and media-services-reset notifications. + /// listens for backgrounding, interruption, foreground activation, and media-services-reset notifications. private func registerObservers() { let center = NotificationCenter.default + observers.append(center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: .main, + ) { [weak self] _ in + self?.handleResignActive() + }) + observers.append(center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main, + ) { [weak self] _ in + self?.handleBecomeActive() + }) observers.append(center.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, @@ -92,24 +105,12 @@ class CaptureSession { ) { [weak self] note in self?.handleInterruption(note) }) - observers.append(center.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main, - ) { [weak self] _ in - guard let self = self, self.isCapturing else { return } - self.bounceEngine() - }) observers.append(center.addObserver( forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main, ) { [weak self] _ in - guard let self = self, self.isCapturing else { return } - do { try self.configureSession() } catch { - print("[YrXtals] post-reset session reconfigure failed: \(error)") - } - self.bounceEngine() + self?.handleMediaServicesReset() }) } @@ -119,23 +120,57 @@ class CaptureSession { observers.removeAll() } - /// reacts to AVAudioSession interruptions: stops the engine on .began, restarts it on .ended. + /// fully tears down on transition out of active state. preserves isCapturing so the resume path knows to restart. + private func handleResignActive() { + teardownEngineAndSession() + print("[YrXtals] CaptureSession torn down on resignActive") + } + + /// runs the full start sequence on return to active state, but only when isCapturing was previously set. + private func handleBecomeActive() { + guard isCapturing else { return } + do { + try fullStartFromScratch() + print("[YrXtals] CaptureSession restarted on becomeActive") + } catch { + print("[YrXtals] CaptureSession restart failed: \(error)") + } + } + + /// reacts to AVAudioSession interruptions: tears down on .began, full restart on .ended. private func handleInterruption(_ note: Notification) { guard let info = note.userInfo, let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } switch type { case .began: - teardownEngine() + teardownEngineAndSession() print("[YrXtals] audio interruption began") case .ended: - if isCapturing { bounceEngine() } - print("[YrXtals] audio interruption ended") + guard isCapturing else { return } + do { + try fullStartFromScratch() + print("[YrXtals] CaptureSession restarted after interruption") + } catch { + print("[YrXtals] CaptureSession restart failed: \(error)") + } @unknown default: break } } + /// handles iOS resetting the audio HAL: full teardown and full restart when we should be capturing. + private func handleMediaServicesReset() { + teardownEngineAndSession() + guard isCapturing else { return } + do { + try fullStartFromScratch() + print("[YrXtals] CaptureSession restarted after media services reset") + } catch { + print("[YrXtals] CaptureSession restart failed: \(error)") + } + } + /// interleaves the per-channel float buffers and forwards the chunk through the FFI. private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) { guard let channelData = buffer.floatChannelData else { return } diff --git a/scripts/android/release-playstore.sh b/scripts/android/release-playstore.sh index 21dea69..34d72dc 100755 --- a/scripts/android/release-playstore.sh +++ b/scripts/android/release-playstore.sh @@ -41,6 +41,17 @@ export YRXTALS_KEY_ALIAS="${YRXTALS_KEY_ALIAS:-yrxtals}" bash "$SCRIPT_DIR/generate-icons.sh" +GRADLE_KTS="$REPO_ROOT/android/app/build.gradle.kts" +CURRENT_CODE="$(grep -E '^[[:space:]]*versionCode = [0-9]+' "$GRADLE_KTS" | head -1 | sed -E 's/.*versionCode = ([0-9]+).*/\1/')" +CURRENT_NAME="$(grep -E '^[[:space:]]*versionName = "[^"]+"' "$GRADLE_KTS" | head -1 | sed -E 's/.*versionName = "([^"]+)".*/\1/')" + +if [[ -z "$CURRENT_CODE" || -z "$CURRENT_NAME" ]]; then + echo "could not parse versionCode / versionName from $GRADLE_KTS" >&2 + exit 1 +fi + +echo "building versionName=$CURRENT_NAME versionCode=$CURRENT_CODE" + JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs" mkdir -p "$JNILIBS/arm64-v8a" @@ -56,7 +67,24 @@ if [[ ! -f "$AAB" ]]; then exit 1 fi +# patch-bumps versionName and increments versionCode by one after a successful build, so the next invocation tags a fresh version. +NEXT_CODE=$((CURRENT_CODE + 1)) +IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_NAME" +if [[ -z "${MAJOR:-}" || -z "${MINOR:-}" || -z "${PATCH:-}" ]]; then + echo "warning: versionName '$CURRENT_NAME' is not semver X.Y.Z; only versionCode will be bumped" >&2 + NEXT_NAME="$CURRENT_NAME" +else + NEXT_PATCH=$((PATCH + 1)) + NEXT_NAME="${MAJOR}.${MINOR}.${NEXT_PATCH}" +fi + +sed -i.bak -E "s/^([[:space:]]*)versionCode = [0-9]+/\\1versionCode = $NEXT_CODE/" "$GRADLE_KTS" +sed -i.bak -E "s/^([[:space:]]*)versionName = \"[^\"]+\"/\\1versionName = \"$NEXT_NAME\"/" "$GRADLE_KTS" +rm -f "$GRADLE_KTS.bak" + echo echo "signed aab: $AAB" echo "applicationId: org.elseif.yrxtals" +echo "shipped versionName=$CURRENT_NAME versionCode=$CURRENT_CODE" +echo "bumped build.gradle.kts to versionName=$NEXT_NAME versionCode=$NEXT_CODE" echo "upload at https://play.google.com/console" diff --git a/src/android.rs b/src/android.rs index c9f8dd3..9ece954 100644 --- a/src/android.rs +++ b/src/android.rs @@ -551,8 +551,8 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapt let pusher = av.inner.pcm_sender(); match MicInput::start(pusher) { Ok(mic) => av.mic_input = Some(mic), - Err(e) => { - dbg_log!("viewportStartMicCapture failed: {e}"); + Err(_e) => { + dbg_log!("viewportStartMicCapture failed: {_e}"); } } } diff --git a/src/mic_input.rs b/src/mic_input.rs index 04b0f93..4d3df63 100644 --- a/src/mic_input.rs +++ b/src/mic_input.rs @@ -14,8 +14,11 @@ pub struct MicInput { impl MicInput { /// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback. pub fn start(pusher: PcmSender) -> Result { + #[cfg(debug_assertions)] let mut frames_since_log: u64 = 0; + #[cfg(debug_assertions)] let mut callbacks_since_log: u64 = 0; + #[cfg(debug_assertions)] let mut peak_since_log: f32 = 0.0; let callback: ndk::audio::AudioStreamDataCallback = Box::new( move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| {