diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 938878c2..5d6b6ccc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
+
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
index b9f81bd9..9af7a73d 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
@@ -1,3 +1,20 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager
@@ -20,6 +37,8 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
+import com.rtbishop.look4sat.core.data.usecase.AudioCapture
+import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -33,6 +52,8 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
+import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
+import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -59,6 +80,10 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideShowToast(): IShowToast = ShowToast(context)
+ override fun provideAudioCapture(): IAudioCapture = AudioCapture()
+
+ override fun provideSaveImage(): ISaveImage = SaveImage(context)
+
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
index c9b72dc8..98a975c8 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
@@ -81,6 +81,7 @@ class SettingsRepo(
private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
+ private val keySstvMode = "sstvMode"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -333,6 +334,7 @@ class SettingsRepo(
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
+ putString(keySstvMode, new.sstvMode)
}
new
}
@@ -346,7 +348,8 @@ class SettingsRepo(
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
- shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
+ shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
+ sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
)
//endregion
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt
new file mode 100644
index 00000000..98d3ce66
--- /dev/null
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt
@@ -0,0 +1,63 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.data.usecase
+
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import androidx.annotation.RequiresPermission
+import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.isActive
+
+class AudioCapture : IAudioCapture {
+
+ override val sampleRate: Int = 44100
+
+ private val channelConfig = AudioFormat.CHANNEL_IN_MONO
+ private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
+ private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
+ .coerceAtLeast(sampleRate) // at least 1 second buffer
+
+ @RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
+ override fun audioFlow(): Flow = flow {
+ val recorder = AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ sampleRate,
+ channelConfig,
+ audioFormat,
+ bufferSize * 4 // bytes for float
+ )
+ try {
+ recorder.startRecording()
+ val chunkSize = sampleRate / 10 // ~100ms chunks
+ val buffer = FloatArray(chunkSize)
+ while (currentCoroutineContext().isActive) {
+ val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
+ if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
+ }
+ } finally {
+ recorder.stop()
+ recorder.release()
+ }
+ }.flowOn(Dispatchers.IO)
+}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt
new file mode 100644
index 00000000..a4f41d9d
--- /dev/null
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt
@@ -0,0 +1,84 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.data.usecase
+
+import android.content.ContentValues
+import android.content.Context
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
+class SaveImage(private val context: Context) : ISaveImage {
+
+ override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
+ return withContext(Dispatchers.IO) {
+ try {
+ val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val filename = "SSTV_${modeName}_$timestamp.png"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveWithMediaStore(bitmap, filename)
+ } else {
+ saveToExternalStorage(bitmap, filename)
+ }
+ bitmap.recycle()
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+ }
+
+ private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
+ val values = ContentValues().apply {
+ put(MediaStore.Images.Media.DISPLAY_NAME, filename)
+ put(MediaStore.Images.Media.MIME_TYPE, "image/png")
+ put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
+ put(MediaStore.Images.Media.IS_PENDING, 1)
+ }
+ val resolver = context.contentResolver
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ ?: throw IllegalStateException("Failed to create MediaStore entry")
+ resolver.openOutputStream(uri)?.use { stream ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ }
+ values.clear()
+ values.put(MediaStore.Images.Media.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ }
+
+ private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
+ val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
+ val dir = File(picturesDir, "Look4Sat")
+ if (!dir.exists()) dir.mkdirs()
+ val file = File(dir, filename)
+ FileOutputStream(file).use { stream ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index 509e0ecd..fcdc2667 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -56,7 +56,8 @@ data class OtherSettings(
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean,
- val shouldSeeWhatsNew: Boolean
+ val shouldSeeWhatsNew: Boolean,
+ val sstvMode: String = "Auto"
)
data class DataSourcesSettings(
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
index c491ab86..a46bbaeb 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
@@ -1,6 +1,25 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
+import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
+import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope
@@ -10,6 +29,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
+ val radioTrackingService: IRadioTrackingService
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -17,7 +37,8 @@ interface IMainContainer {
fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
- val radioTrackingService: IRadioTrackingService
+ fun provideAudioCapture(): IAudioCapture
+ fun provideSaveImage(): ISaveImage
}
interface IContainerProvider {
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt
new file mode 100644
index 00000000..0651e2e4
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt
@@ -0,0 +1,573 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.sstv
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.withContext
+import kotlin.math.abs
+import kotlin.math.round
+import kotlin.math.sqrt
+
+class SstvFrame(
+ val imagePixels: IntArray?,
+ val imageWidth: Int,
+ val imageHeight: Int,
+ val modeName: String
+)
+
+class SstvDecoder(
+ sampleRate: Int = 44100,
+ scopeWidth: Int = 320,
+ scopeHeight: Int = 256,
+ private val channelSelect: Int = 0
+) {
+ private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
+ private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
+ private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
+ private val _frames = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val frames: SharedFlow = _frames
+ val supportedModes: List = buildList {
+ add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
+ }
+
+ suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
+ val hasNewLines = decoder.process(samples, channelSelect)
+ if (hasNewLines) emitFrame()
+ }
+
+ fun lockMode(modeName: String) = decoder.setMode(modeName)
+
+ fun clearPixels() {
+ imageBuffer.line = -1
+ imageBuffer.pixels.fill(0)
+ }
+
+ private fun emitFrame() {
+ val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
+ val imageWidth = imageBuffer.width
+ val imageHeight = imageBuffer.height
+ val modeName = decoder.currentMode.name
+ _frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
+ }
+}
+
+internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
+
+internal class SyncPulseDetector(sampleRate: Int) {
+
+ companion object {
+ const val SYNC_FREQ = 1200.0
+ const val BLACK_FREQ = 1500.0
+ const val WHITE_FREQ = 2300.0
+ }
+
+ var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
+ var pulseOffset: Int = 0; private set
+ var freqOffset: Float = 0f; private set
+
+ private val bandwidth = WHITE_FREQ - BLACK_FREQ
+ private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
+ private val min5ms: Int = round(0.0025 * sampleRate).toInt()
+ private val max5ms: Int = round(0.007 * sampleRate).toInt()
+ private val max9ms: Int = round(0.0145 * sampleRate).toInt()
+ private val max20ms: Int = round(0.025 * sampleRate).toInt()
+ private val filterDelay: Int
+ private val avgFilter: MovingAverage
+ private val delayLine: Delay
+ private val lowPass: ComplexFirFilter
+ private val oscillator: Phasor
+ private val syncFreqValue: Float
+ private val syncFreqTolerance: Float
+ private val trigger: SchmittTrigger
+ private var counter = 0
+ private var baseBand = Complex()
+
+ init {
+ val filterLen = round(0.0025 * sampleRate).toInt() or 1
+ filterDelay = (filterLen - 1) / 2
+ avgFilter = MovingAverage(filterLen)
+ delayLine = Delay(filterLen)
+ val loFreq = 1000.0
+ val hiFreq = 2800.0
+ val cutoff = (hiFreq - loFreq) / 2
+ val lpLen = round(0.002 * sampleRate).toInt() or 1
+ lowPass = ComplexFirFilter(lpLen)
+ for (i in 0 until lpLen)
+ lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
+ cutoff,
+ sampleRate.toDouble(),
+ i,
+ lpLen
+ )).toFloat()
+ val center = (loFreq + hiFreq) / 2
+ oscillator = Phasor(-center, sampleRate.toDouble())
+ syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
+ syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
+ val porchFreq = 1500.0
+ val hiThresh = (SYNC_FREQ + porchFreq) / 2
+ val loThresh = (SYNC_FREQ + hiThresh) / 2
+ trigger = SchmittTrigger(
+ ((loThresh - center) * 2 / bandwidth).toFloat(),
+ ((hiThresh - center) * 2 / bandwidth).toFloat()
+ )
+ }
+
+ fun process(buffer: FloatArray, channelSelect: Int): Boolean {
+ var detected = false
+ val channels = if (channelSelect > 0) 2 else 1
+ for (i in 0 until buffer.size / channels) {
+ when (channelSelect) {
+ 1 -> baseBand.set(buffer[2 * i])
+ 2 -> baseBand.set(buffer[2 * i + 1])
+ 3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
+ 4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
+ else -> baseBand.set(buffer[i])
+ }
+ baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
+ val freq = fm.demodulate(baseBand)
+ val avg = avgFilter.avg(freq)
+ val delayed = delayLine.push(avg)
+ buffer[i] = freq
+ if (!trigger.process(avg)) {
+ ++counter
+ } else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
+ counter = 0
+ } else {
+ detectedWidth = when {
+ counter < max5ms -> SyncPulseWidth.FiveMs
+ counter < max9ms -> SyncPulseWidth.NineMs
+ else -> SyncPulseWidth.TwentyMs
+ }
+ pulseOffset = i - filterDelay
+ freqOffset = delayed - syncFreqValue
+ detected = true
+ counter = 0
+ }
+ }
+ return detected
+ }
+}
+
+internal class DecoderEngine(
+ private val scopeBuffer: PixelBuffer,
+ private val imageBuffer: PixelBuffer,
+ rawName: String,
+ sampleRate: Int
+) {
+ private val pixelBuffer = PixelBuffer(800, 2)
+ private val detector = SyncPulseDetector(sampleRate)
+ private val pulseFilter: MovingAverage
+ private val pulseFilterDelay: Int
+ private val scanLineBuffer: FloatArray
+ private val scratch: FloatArray
+ private val sync5ms = IntArray(5)
+ private val sync9ms = IntArray(5)
+ private val sync20ms = IntArray(5)
+ private val lines5ms = IntArray(4)
+ private val lines9ms = IntArray(4)
+ private val lines20ms = IntArray(4)
+ private val offsets5ms = FloatArray(5)
+ private val offsets9ms = FloatArray(5)
+ private val offsets20ms = FloatArray(5)
+ private val visFreqs = FloatArray(10)
+ private val scanLineMin: Int
+ private val syncTolerance: Int
+ private val lineTolerance: Int
+ private val leaderLen: Int
+ private val leaderTol: Int
+ private val transition: Int
+ private val visBitLen: Int
+ private val visLen: Int
+ private val rawMode: SstvMode
+ private val hfFaxMode: SstvMode
+ private val modes5ms: ArrayList
+ private val modes9ms: ArrayList
+ private val modes20ms: ArrayList
+
+ var currentMode: SstvMode; private set
+ val allModes: List get() = modes5ms + modes9ms + modes20ms
+
+ private var lockMode = false
+ private var sample = 0
+ private var leaderBreak = 0
+ private var lastSync = 0
+ private var curLineSamples: Int
+ private var lastOffset = 0f
+
+ init {
+ imageBuffer.line = -1
+ val pfLen = round(0.0025 * sampleRate).toInt() or 1
+ pulseFilterDelay = (pfLen - 1) / 2
+ pulseFilter = MovingAverage(pfLen)
+ scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
+ scratch = FloatArray(round(1.1 * sampleRate).toInt())
+ leaderLen = round(0.3 * sampleRate).toInt()
+ leaderTol = round(0.06 * sampleRate).toInt()
+ transition = round(0.0005 * sampleRate).toInt()
+ visBitLen = round(0.03 * sampleRate).toInt()
+ visLen = round(0.3 * sampleRate).toInt()
+ scanLineMin = round(0.05 * sampleRate).toInt()
+ syncTolerance = round(0.03 * sampleRate).toInt()
+ lineTolerance = round(0.001 * sampleRate).toInt()
+ rawMode = RawMode(rawName, sampleRate)
+ hfFaxMode = HfFaxMode(sampleRate)
+ val robot36 = Robot36Mode(sampleRate)
+ currentMode = robot36
+ curLineSamples = robot36.scanLineSamples
+ modes5ms = arrayListOf(
+ RgbMode.wraaseSc2180(sampleRate),
+ RgbMode.martin("1", 44, 0.146432, sampleRate),
+ RgbMode.martin("2", 40, 0.073216, sampleRate)
+ )
+ modes9ms = arrayListOf(
+ robot36, Robot72Mode(sampleRate),
+ RgbMode.scottie("1", 60, 0.138240, sampleRate),
+ RgbMode.scottie("2", 56, 0.088064, sampleRate),
+ RgbMode.scottie("DX", 76, 0.3456, sampleRate)
+ )
+ modes20ms = arrayListOf(
+ PdMode("50", 93, 320, 256, 0.09152, sampleRate),
+ PdMode("90", 99, 320, 256, 0.17024, sampleRate),
+ PdMode("120", 95, 640, 496, 0.1216, sampleRate),
+ PdMode("160", 98, 512, 400, 0.195584, sampleRate),
+ PdMode("180", 96, 640, 496, 0.18304, sampleRate),
+ PdMode("240", 97, 640, 496, 0.24448, sampleRate),
+ PdMode("290", 94, 800, 616, 0.2288, sampleRate)
+ )
+ }
+
+ fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
+ var newLines = false
+ val detected = detector.process(recordBuffer, channelSelect)
+ var syncIdx = sample + detector.pulseOffset
+ val channels = if (channelSelect > 0) 2 else 1
+ for (j in 0 until recordBuffer.size / channels) {
+ if (sample >= scanLineBuffer.size) {
+ shift(curLineSamples)
+ syncIdx -= curLineSamples
+ if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
+ }
+ scanLineBuffer[sample++] = recordBuffer[j]
+ }
+ if (detected) {
+ when (detector.detectedWidth) {
+ SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
+ SyncPulseWidth.NineMs -> {
+ leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
+ }
+
+ SyncPulseWidth.TwentyMs -> {
+ leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
+ }
+ }
+ } else if (handleHeader()) {
+ newLines = true
+ } else if (sample > lastSync + (curLineSamples * 5) / 4) {
+ copyLines(
+ currentMode.decodeScanLine(
+ pixelBuffer,
+ scratch,
+ scanLineBuffer,
+ scopeBuffer.width,
+ lastSync,
+ curLineSamples,
+ lastOffset
+ )
+ )
+ lastSync += curLineSamples; newLines = true
+ }
+ return newLines
+ }
+
+ fun setMode(name: String) {
+ if (rawMode.name == name) {
+ lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return
+ }
+ var mode = allModes.firstOrNull { it.name == name }
+ if (mode == null && hfFaxMode.name == name) mode = hfFaxMode
+ if (mode == currentMode) {
+ lockMode = true; return
+ }
+ if (mode != null) {
+ lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
+ }
+ lockMode = false
+ }
+
+ private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
+ private fun stdDev(a: IntArray, m: Double): Double {
+ var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
+ }
+
+ private fun meanF(a: FloatArray): Float {
+ var s = 0f; for (v in a) s += v; return s / a.size
+ }
+
+ private fun detectMode(modes: ArrayList, samples: Int): SstvMode {
+ var best: SstvMode = rawMode
+ var bestD = Int.MAX_VALUE
+ for (m in modes) {
+ val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
+ bestD = d; best = m
+ }
+ }
+ return best
+ }
+
+ private fun copyUnscaled() {
+ val w = minOf(scopeBuffer.width, pixelBuffer.width)
+ for (row in 0 until pixelBuffer.height) {
+ val line = scopeBuffer.width * scopeBuffer.line
+ pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
+ scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
+
+ scopeBuffer.pixels.copyInto(
+ scopeBuffer.pixels,
+ scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
+ line,
+ line + scopeBuffer.width
+ )
+ scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
+ }
+ }
+
+ private fun copyScaled(scale: Int) {
+ for (row in 0 until pixelBuffer.height) {
+ val line = scopeBuffer.width * scopeBuffer.line
+ for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
+ pixelBuffer.pixels[pixelBuffer.width * row + col]
+ scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
+
+ scopeBuffer.pixels.copyInto(
+ scopeBuffer.pixels,
+ scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
+ line,
+ line + scopeBuffer.width
+ )
+ scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
+ repeat(scale - 1) {
+
+ scopeBuffer.pixels.copyInto(
+ scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
+ )
+
+ scopeBuffer.pixels.copyInto(
+ scopeBuffer.pixels,
+ scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
+ line,
+ line + scopeBuffer.width
+ )
+ scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
+ }
+ }
+ }
+
+ private fun copyLines(ok: Boolean) {
+ if (!ok) return
+ var finish = false
+ if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
+ val w = imageBuffer.width
+ for (row in 0 until pixelBuffer.height) {
+ if (imageBuffer.line >= imageBuffer.height) break
+ pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
+ imageBuffer.line++
+ }
+ finish = imageBuffer.line == imageBuffer.height
+ }
+ val scale = scopeBuffer.width / pixelBuffer.width
+ if (scale <= 1) copyUnscaled() else copyScaled(scale)
+ if (finish) drawLines(0xff000000.toInt(), 10)
+ }
+
+ private fun drawLines(color: Int, count: Int) {
+ repeat(count) {
+ scopeBuffer.pixels.fill(
+ color,
+ scopeBuffer.line * scopeBuffer.width,
+ (scopeBuffer.line + 1) * scopeBuffer.width
+ )
+ scopeBuffer.pixels.fill(
+ color,
+ (scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
+ (scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
+ )
+ scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
+ }
+ }
+
+ private fun adjust(pulses: IntArray, shift: Int) {
+ for (i in pulses.indices) pulses[i] -= shift
+ }
+
+ private fun shift(amount: Int) {
+ if ((amount <= 0) || (amount > sample)) return
+ sample -= amount; leaderBreak -= amount; lastSync -= amount
+ adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
+ scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
+ }
+
+ private fun handleHeader(): Boolean {
+ if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
+ val bp = leaderBreak; leaderBreak = 0
+ var preFreq = 0f
+ for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
+ val toneFreq = 1900f
+ val center = 1900f
+ val tol = 50f
+ val halfBw = 400f
+ preFreq = preFreq * halfBw / leaderTol + center
+ if (abs(preFreq - toneFreq) > tol) return false
+ var ldrFreq = 0f
+ for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
+ val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
+ ldrFreq = ldrOffset * halfBw + center
+ if (abs(ldrFreq - toneFreq) > tol) return false
+ val stopFreq = 1200f
+ val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
+ var vBegin = bp + leaderLen - leaderTol
+ val vEnd = bp + leaderLen + leaderTol + visBitLen
+ repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
+ while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
+ if (vBegin >= vEnd) return false
+ vBegin -= pulseFilterDelay
+ val visEnd = vBegin + visLen
+ visFreqs.fill(0f)
+ for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
+ for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
+ if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
+ for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
+ var vis = 0
+ for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
+ var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
+ vis = vis and 127; if (!chk) return false
+ val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
+ var sIdx = visEnd - visBitLen
+ val sMax = visEnd + visBitLen
+ repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
+ while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
+ if (sIdx >= sMax) return false
+ sIdx -= pulseFilterDelay
+ val mode: SstvMode
+ val pulses: IntArray
+ val lines: IntArray
+ val f5 = modes5ms.firstOrNull { it.visCode == vis }
+ val f9 = modes9ms.firstOrNull { it.visCode == vis }
+ val f20 = modes20ms.firstOrNull { it.visCode == vis }
+ when {
+ f5 != null -> {
+ mode = f5; pulses = sync5ms; lines = lines5ms
+ }
+
+ f9 != null -> {
+ mode = f9; pulses = sync9ms; lines = lines9ms
+ }
+
+ f20 != null -> {
+ mode = f20; pulses = sync20ms; lines = lines20ms
+ }
+
+ else -> {
+ if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
+ }
+ }
+ if (lockMode && mode != currentMode) return false
+ mode.resetState()
+ imageBuffer.width = mode.width; imageBuffer.height = mode.height
+ imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0
+ currentMode = mode
+ lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
+ var oldest = lastSync - (pulses.size - 1) * curLineSamples
+ if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
+ for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
+ lines.fill(curLineSamples)
+ shift(lastSync + mode.firstPixelSampleIndex)
+ drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
+ return true
+ }
+
+ private fun processPulse(
+ modes: ArrayList,
+ freqOffs: FloatArray,
+ syncPulses: IntArray,
+ lineLen: IntArray,
+ latest: Int
+ ): Boolean {
+ for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
+ syncPulses[syncPulses.size - 1] = latest
+ for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
+ lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
+ for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
+ freqOffs[freqOffs.size - 1] = detector.freqOffset
+ if (lineLen[0] == 0) return false
+ val m = mean(lineLen)
+ val lineSamples = round(m).toInt()
+ if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
+ if (stdDev(lineLen, m) > lineTolerance) return false
+ var changed = false
+ if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
+ if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
+ } else {
+ val prev = currentMode; currentMode = detectMode(modes, lineSamples)
+ changed =
+ currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
+ }
+ if (changed) {
+ drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
+ }
+ val offset = meanF(freqOffs)
+ if (syncPulses[0] >= lineSamples && changed) {
+ val end = syncPulses[0]
+ val extra = end / lineSamples
+ val first = end - extra * lineSamples
+ var p = first; while (p < end) {
+ copyLines(
+ currentMode.decodeScanLine(
+ pixelBuffer,
+ scratch,
+ scanLineBuffer,
+ scopeBuffer.width,
+ p,
+ lineSamples,
+ offset
+ )
+ ); p += lineSamples
+ }
+ }
+ val start = if (changed) 0 else lineLen.size - 1
+ for (i in start until lineLen.size) copyLines(
+ currentMode.decodeScanLine(
+ pixelBuffer,
+ scratch,
+ scanLineBuffer,
+ scopeBuffer.width,
+ syncPulses[i],
+ lineLen[i],
+ offset
+ )
+ )
+ lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
+ shift(lastSync + currentMode.firstPixelSampleIndex)
+ return true
+ }
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt
new file mode 100644
index 00000000..71dd4e62
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt
@@ -0,0 +1,216 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.sstv
+
+import kotlin.math.PI
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.round
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
+
+ fun set(real: Float, imag: Float): Complex {
+ this.real = real; this.imag = imag; return this
+ }
+
+ fun set(real: Float): Complex = set(real, 0f)
+
+ fun abs(): Float = sqrt(real * real + imag * imag)
+ fun arg(): Float = atan2(imag, real)
+
+ fun mul(other: Complex): Complex {
+ val tmp = real * other.real - imag * other.imag
+ imag = real * other.imag + imag * other.real
+ real = tmp
+ return this
+ }
+
+ fun div(value: Float): Complex {
+ real /= value; imag /= value; return this
+ }
+}
+
+internal object WindowFunctions {
+
+ fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
+ val f = 2 * cutoff / rate
+ val x = n - (nN - 1) / 2.0
+ val fx = f * x
+ return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
+ }
+
+ fun kaiser(a: Double, n: Int, nN: Int): Double {
+ fun square(v: Double) = v * v
+ fun i0(x: Double): Double {
+ val terms = DoubleArray(35)
+ terms[0] = 1.0
+ var v = 1.0
+ for (m in 1 until 35) {
+ v *= x / (2 * m); terms[m] = square(v)
+ }
+ terms.sort()
+ var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
+ return sum
+ }
+ return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
+ }
+}
+
+internal open class MovingSum(val length: Int) {
+ private val tree = FloatArray(2 * length)
+ private var leaf = length
+
+ fun add(input: Float) {
+ tree[leaf] = input
+ var child = leaf
+ var parent = leaf / 2
+ while (parent > 0) {
+ tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
+ }
+ if (++leaf >= tree.size) leaf = length
+ }
+
+ fun sum(): Float = tree[1]
+ fun sum(input: Float): Float {
+ add(input); return sum()
+ }
+}
+
+internal class MovingAverage(length: Int) : MovingSum(length) {
+ fun avg(input: Float): Float = sum(input) / length
+}
+
+internal class Ema {
+ private var alpha: Float = 1f
+ private var prev: Float = 0f
+
+ fun process(input: Float): Float {
+ prev = prev * (1 - alpha) + alpha * input; return prev
+ }
+
+ fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
+ alpha = computeAlpha(freq, rate, order)
+ }
+
+ fun reset() {
+ prev = 0f
+ }
+
+ companion object {
+
+ fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
+ val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
+ val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
+ return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
+ }
+
+ fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
+ return Ema().also { it.setCutoff(freq, rate, order) }
+ }
+ }
+}
+
+internal class Phasor(freq: Double, rate: Double) {
+ private val value = Complex(1f, 0f)
+ private val delta: Complex = run {
+ val omega = 2 * PI * freq / rate
+ Complex(cos(omega).toFloat(), sin(omega).toFloat())
+ }
+
+ fun rotate(): Complex = value.div(value.mul(delta).abs())
+}
+
+internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
+ private val scale = (sampleRate / (bandwidth * PI)).toFloat()
+ private val pi = PI.toFloat()
+ private val twoPi = (2 * PI).toFloat()
+ private var prev = 0f
+
+ fun demodulate(input: Complex): Float {
+ val phase = input.arg()
+ var delta = phase - prev; prev = phase
+ if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
+ return scale * delta
+ }
+}
+
+internal class ComplexFirFilter(val length: Int) {
+ private val real = FloatArray(length)
+ private val imag = FloatArray(length)
+ private val sum = Complex()
+ private var pos = 0
+
+ val taps = FloatArray(length)
+
+ fun filter(input: Complex): Complex {
+ real[pos] = input.real; imag[pos] = input.imag
+ if (++pos >= length) pos = 0
+ sum.real = 0f; sum.imag = 0f
+ for (tap in taps) {
+ sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
+ }
+ return sum
+ }
+}
+
+internal class Delay(val length: Int) {
+ private val buf = FloatArray(length)
+ private var pos = 0
+
+ fun push(input: Float): Float {
+ val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
+ }
+}
+
+internal class SchmittTrigger(private val low: Float, private val high: Float) {
+ private var state = false
+
+ fun process(input: Float): Boolean {
+ if (state) {
+ if (input < low) state = false
+ } else {
+ if (input > high) state = true
+ }
+ return state
+ }
+}
+
+internal object ColorConverter {
+
+ private fun clamp(v: Int) = v.coerceIn(0, 255)
+ private fun toInt(level: Float) = clamp(round(255 * level).toInt())
+ private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
+
+ private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
+ val y = yY - 16
+ val u = uU - 128
+ val v = vV - 128
+ val r = clamp((298 * y + 409 * v + 128) shr 8)
+ val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
+ val b = clamp((298 * y + 516 * u + 128) shr 8)
+ return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
+ }
+
+ fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
+ fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
+ fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
+ fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt
new file mode 100644
index 00000000..1e574018
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt
@@ -0,0 +1,474 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.sstv
+
+import kotlin.math.round
+
+internal class PixelBuffer(var width: Int, var height: Int) {
+ var pixels = IntArray(width * height)
+ var line = 0
+}
+
+internal sealed interface SstvMode {
+ val name: String
+ val visCode: Int
+ val width: Int
+ val height: Int
+ val firstPixelSampleIndex: Int
+ val firstSyncPulseIndex: Int
+ val scanLineSamples: Int
+
+ fun resetState() {}
+
+ fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean
+}
+
+internal class RgbMode(
+ override val name: String,
+ override val visCode: Int,
+ private val hPixels: Int,
+ private val vPixels: Int,
+ override val firstSyncPulseIndex: Int,
+ override val scanLineSamples: Int,
+ override val firstPixelSampleIndex: Int,
+ private val redBegin: Int,
+ private val redLen: Int,
+ private val greenBegin: Int,
+ private val greenLen: Int,
+ private val blueBegin: Int,
+ private val blueLen: Int,
+ private val endSamples: Int,
+) : SstvMode {
+ override val width get() = hPixels
+ override val height get() = vPixels
+ private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ val begin = firstPixelSampleIndex
+ if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
+ ema.reset()
+ for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
+ ema.reset()
+ for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ for (i in 0 until hPixels) {
+ val r = redBegin + (i * redLen) / hPixels
+ val g = greenBegin + (i * greenLen) / hPixels
+ val b = blueBegin + (i * blueLen) / hPixels
+ pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
+ }
+ pixelBuffer.width = hPixels; pixelBuffer.height = 1
+ return true
+ }
+
+ companion object {
+ fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
+ val sync = 0.004862
+ val sep = 0.000572
+ val scanLine = sync + sep + 3 * (channelSec + sep)
+ val gEnd = sep + channelSec
+ val bBegin = gEnd + sep
+ val bEnd = bBegin + channelSec
+ val rBegin = bEnd + sep
+ val rEnd = rBegin + channelSec
+ return fromSeconds(
+ name = "Martin $variant",
+ code = code,
+ firstSyncSec = 0.0,
+ scanLineSec = scanLine,
+ beginSec = sep,
+ rBeginSec = rBegin,
+ rEndSec = rEnd,
+ gBeginSec = sep,
+ gEndSec = gEnd,
+ bBeginSec = bBegin,
+ bEndSec = bEnd,
+ endSec = rEnd,
+ sr = sampleRate
+ )
+ }
+
+ fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
+ val sync = 0.009
+ val sep = 0.0015
+ val firstSync = sync + 2 * (sep + channelSec)
+ val scanLine = sync + 3 * (channelSec + sep)
+ val bEnd = -sync
+ val bBegin = bEnd - channelSec
+ val gEnd = bBegin - sep
+ val gBegin = gEnd - channelSec
+ val rEnd = sep + channelSec
+ return fromSeconds(
+ name = "Scottie $variant",
+ code = code,
+ firstSyncSec = firstSync,
+ scanLineSec = scanLine,
+ beginSec = gBegin,
+ rBeginSec = sep,
+ rEndSec = rEnd,
+ gBeginSec = gBegin,
+ gEndSec = gEnd,
+ bBeginSec = bBegin,
+ bEndSec = bEnd,
+ endSec = rEnd,
+ sr = sampleRate
+ )
+ }
+
+ fun wraaseSc2180(sampleRate: Int): RgbMode {
+ val sync = 0.0055225
+ val porch = 0.0005
+ val ch = 0.235
+ val scanLine = sync + porch + 3 * ch
+ val rEnd = porch + ch
+ val gEnd = rEnd + ch
+ val bEnd = gEnd + ch
+ return fromSeconds(
+ name = "Wraase SC2-180",
+ code = 55,
+ firstSyncSec = 0.0,
+ scanLineSec = scanLine,
+ beginSec = porch,
+ rBeginSec = porch,
+ rEndSec = rEnd,
+ gBeginSec = rEnd,
+ gEndSec = gEnd,
+ bBeginSec = gEnd,
+ bEndSec = bEnd,
+ endSec = bEnd,
+ sr = sampleRate
+ )
+ }
+
+ private fun fromSeconds(
+ name: String, code: Int, w: Int = 320, h: Int = 256,
+ firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
+ rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
+ bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
+ ): RgbMode {
+ val begin = round(beginSec * sr).toInt()
+ return RgbMode(
+ name = name, visCode = code, hPixels = w, vPixels = h,
+ firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
+ scanLineSamples = round(scanLineSec * sr).toInt(),
+ firstPixelSampleIndex = begin,
+ redBegin = round(rBeginSec * sr).toInt() - begin,
+ redLen = round((rEndSec - rBeginSec) * sr).toInt(),
+ greenBegin = round(gBeginSec * sr).toInt() - begin,
+ greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
+ blueBegin = round(bBeginSec * sr).toInt() - begin,
+ blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
+ endSamples = round(endSec * sr).toInt()
+ )
+ }
+ }
+}
+
+internal class Robot36Mode(sampleRate: Int) : SstvMode {
+ override val name = "Robot 36 Color"
+ override val visCode = 8
+ override val width = 320
+ override val height = 240
+ override val firstSyncPulseIndex = 0
+ override val scanLineSamples: Int
+ override val firstPixelSampleIndex: Int
+
+ private val lumSamples: Int
+ private val sepSamples: Int
+ private val chromSamples: Int
+ private val lumBegin: Int
+ private val sepBegin: Int
+ private val chromBegin: Int
+ private val end: Int
+ private val ema: Ema
+ private var lastEven = false
+
+ init {
+ val syncPorch = 0.003
+ val lum = 0.088
+ val sep = 0.0045
+ val porch = 0.0015
+ val chrom = 0.044
+ scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
+ lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
+ chromSamples = round(chrom * sampleRate).toInt()
+ lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
+ sepBegin = round((syncPorch + lum) * sampleRate).toInt()
+ chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
+ end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
+ ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
+ }
+
+ override fun resetState() {
+ lastEven = false
+ }
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
+ var sep = 0f
+ for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
+ sep = sep / sepSamples - freqOffset
+ var even = sep < 0
+ if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
+ lastEven = even
+ ema.reset()
+ for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
+ ema.reset()
+ for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ for (i in 0 until width) {
+ val lPos = lumBegin + (i * lumSamples) / width
+ val cPos = chromBegin + (i * chromSamples) / width
+ if (even) {
+ pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
+ } else {
+ val evenYuv = pixelBuffer.pixels[i]
+ val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
+ pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
+ pixelBuffer.pixels[i + width] =
+ ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
+ }
+ }
+ pixelBuffer.width = width; pixelBuffer.height = 2
+ return !even
+ }
+}
+
+internal class Robot72Mode(sampleRate: Int) : SstvMode {
+ override val name = "Robot 72 Color"
+ override val visCode = 12
+ override val width = 320
+ override val height = 240
+ override val firstSyncPulseIndex = 0
+ override val scanLineSamples: Int
+ override val firstPixelSampleIndex: Int
+
+ private val lumSamples: Int
+ private val chromSamples: Int
+ private val yBegin: Int
+ private val vBegin: Int
+ private val uBegin: Int
+ private val end: Int
+ private val ema: Ema
+
+ init {
+ val syncPorch = 0.003
+ val lum = 0.138
+ val sep = 0.0045
+ val porch = 0.0015
+ val chrom = 0.069
+ scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
+ lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
+ yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
+ vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
+ uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
+ end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
+ ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
+ }
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
+ ema.reset()
+ for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
+ ema.reset()
+ for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ for (i in 0 until width) {
+ val yP = yBegin + (i * lumSamples) / width
+ val uP = uBegin + (i * chromSamples) / width
+ val vP = vBegin + (i * chromSamples) / width
+ pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
+ }
+ pixelBuffer.width = width; pixelBuffer.height = 1
+ return true
+ }
+}
+
+internal class PdMode(
+ variant: String,
+ override val visCode: Int,
+ private val hPixels: Int,
+ private val vPixels: Int,
+ channelSec: Double,
+ sampleRate: Int
+) : SstvMode {
+ override val name = "PD $variant"
+ override val width get() = hPixels
+ override val height get() = vPixels
+ override val firstSyncPulseIndex = 0
+ override val scanLineSamples: Int
+ override val firstPixelSampleIndex: Int
+
+ private val chSamples: Int
+ private val yEvenBegin: Int
+ private val vAvgBegin: Int
+ private val uAvgBegin: Int
+ private val yOddBegin: Int
+ private val end: Int
+ private val ema: Ema
+
+ init {
+ val syncPorch = 0.00208
+ scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
+ chSamples = round(channelSec * sampleRate).toInt()
+ yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
+ vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
+ uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
+ yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
+ end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
+ ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
+ }
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
+ ema.reset()
+ for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
+ ema.reset()
+ for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ for (i in 0 until hPixels) {
+ val pos = (i * chSamples) / hPixels
+ pixelBuffer.pixels[i] =
+ ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
+ pixelBuffer.pixels[i + hPixels] =
+ ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
+ }
+ pixelBuffer.width = hPixels; pixelBuffer.height = 2
+ return true
+ }
+}
+
+internal class HfFaxMode(private val sampleRate: Int) : SstvMode {
+ override val name = "HF Fax"
+ override val visCode = -1
+ override val width = 640
+ override val height = 1200
+ override val firstPixelSampleIndex = 0
+ override val firstSyncPulseIndex = -1
+ override val scanLineSamples get() = sampleRate / 2
+
+ private val ema = Ema()
+ private val cumulated = FloatArray(width)
+
+ var horizontalShift = 0; private set
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
+ ema.setCutoff(width.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
+ for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
+ ema.reset()
+ for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ var bestIdx = 0
+ var bestVal = 0f
+ for (i in 0 until width) {
+ val pos = (i * lineSamples) / width
+ val color = ColorConverter.gray(scratch[pos])
+ pixelBuffer.pixels[i] = color
+ // Grayscale: R==G==B, so luminance is simply the channel value normalized
+ val gray = (color and 0xFF) / 255f
+ cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f
+ if (cumulated[i] > bestVal) {
+ bestIdx = i; bestVal = cumulated[i]
+ }
+ }
+ horizontalShift = bestIdx
+ pixelBuffer.width = width; pixelBuffer.height = 1
+ return true
+ }
+}
+
+internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
+ override val visCode = -1
+ override val width = -1
+ override val height = -1
+ override val firstPixelSampleIndex = 0
+ override val firstSyncPulseIndex = -1
+ override val scanLineSamples = -1
+
+ private val smallMax = round(0.125 * sampleRate).toInt()
+ private val medMax = round(0.175 * sampleRate).toInt()
+ private val ema = Ema()
+
+ override fun decodeScanLine(
+ pixelBuffer: PixelBuffer,
+ scratch: FloatArray,
+ scanLine: FloatArray,
+ scopeWidth: Int,
+ syncPulseIndex: Int,
+ lineSamples: Int,
+ freqOffset: Float
+ ): Boolean {
+ if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
+ var px = scopeWidth
+ if (lineSamples < smallMax) px /= 2
+ if (lineSamples < medMax) px /= 2
+ ema.setCutoff(px.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
+ for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
+ ema.reset()
+ for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
+ for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
+ pixelBuffer.width = px; pixelBuffer.height = 1
+ return true
+ }
+}
+
+private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt
new file mode 100644
index 00000000..53a36648
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt
@@ -0,0 +1,34 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.usecase
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Platform abstraction for microphone audio capture.
+ * Produces a flow of mono Float PCM buffers at the configured sample rate.
+ */
+interface IAudioCapture {
+ val sampleRate: Int
+
+ /**
+ * Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
+ * Caller is responsible for holding RECORD_AUDIO permission before calling this.
+ */
+ fun audioFlow(): Flow
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt
new file mode 100644
index 00000000..0c3ff4c0
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt
@@ -0,0 +1,26 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.usecase
+
+/**
+ * Saves a decoded SSTV image to the device gallery.
+ * @return true if the image was saved successfully
+ */
+interface ISaveImage {
+ suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
+}
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
index 9c3b3796..36f94aad 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
@@ -208,14 +208,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
}
@Composable
-fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
- ElevatedCard(modifier = Modifier.size(48.dp)) {
- Box(
- modifier = Modifier
- .clickable(onClick = action)
- .fillMaxSize(),
- contentAlignment = Alignment.Center
- ) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
+fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
+ }
}
}
diff --git a/core/presentation/src/main/res/drawable/ic_delete.xml b/core/presentation/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 00000000..6c6f704a
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/presentation/src/main/res/drawable/ic_pause.xml b/core/presentation/src/main/res/drawable/ic_pause.xml
new file mode 100644
index 00000000..2ffb66b3
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_pause.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/presentation/src/main/res/drawable/ic_play.xml b/core/presentation/src/main/res/drawable/ic_play.xml
new file mode 100644
index 00000000..88f62917
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/presentation/src/main/res/drawable/ic_save.xml b/core/presentation/src/main/res/drawable/ic_save.xml
new file mode 100644
index 00000000..56aad040
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_save.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
index 5cf92601..73b81a90 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
@@ -17,6 +17,10 @@
*/
package com.rtbishop.look4sat.feature.radar
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -39,6 +43,7 @@ import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -49,6 +54,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -76,14 +82,27 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- RadarScreen(uiState, viewModel::onAction, navigateUp)
+ // Sync actual permission state on every recomposition so it survives screen re-entry
+ val hasPermission = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ LaunchedEffect(hasPermission) {
+ viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
+ }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
+ RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
+ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ })
}
@Composable
private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
- navigateUp: () -> Unit
+ navigateUp: () -> Unit,
+ requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -113,11 +132,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, Modifier.weight(1f))
- PagerCard(uiState, onAction, Modifier.weight(1f))
+ PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, Modifier.weight(1f))
- PagerCard(uiState, onAction, Modifier.weight(1f))
+ PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
}
}
}
@@ -127,6 +146,7 @@ private fun RadarScreen(
private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
+ requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
@@ -155,38 +175,17 @@ private fun PagerCard(
radioControl = uiState.radioControl,
onAction = onAction
)
- RadarPage.Sstv -> SstvPlaceholderPage()
+ RadarPage.Sstv -> SstvPage(
+ sstv = uiState.sstv,
+ onAction = onAction,
+ requestMicPermission = requestMicPermission
+ )
}
}
}
}
}
-@Composable
-private fun SstvPlaceholderPage() {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.fillMaxSize()
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(text = "π§", fontSize = 48.sp)
- Text(
- text = "SSTV Decoder",
- fontSize = 20.sp,
- color = MaterialTheme.colorScheme.primary
- )
- Text(
- text = "Coming soon",
- fontSize = 16.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-}
-
@Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
index a10adf40..f36795c9 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
@@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
+import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
data class RadioPanelState(
val label: String = "",
@@ -57,7 +58,18 @@ data class RadarState(
val transmitters: List = emptyList(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null,
- val radioControl: RadioControlSubState = RadioControlSubState()
+ val radioControl: RadioControlSubState = RadioControlSubState(),
+ val sstv: SstvSubState = SstvSubState()
+)
+
+enum class SstvStatus { Idle, Recording, Saving }
+
+data class SstvSubState(
+ val status: SstvStatus = SstvStatus.Idle,
+ val hasPermission: Boolean = false,
+ val selectedMode: String = "Auto",
+ val supportedModes: List = emptyList(),
+ val currentFrame: SstvFrame? = null
)
sealed interface RadarAction {
@@ -72,4 +84,12 @@ sealed interface RadarAction {
data object ToggleTracking : RadarAction
data object ConnectRadios : RadarAction
data object DisconnectRadios : RadarAction
+
+ // SSTV actions
+ data object SstvStartRecording : RadarAction
+ data object SstvStopRecording : RadarAction
+ data object SstvSaveImage : RadarAction
+ data object SstvReset : RadarAction
+ data class SstvSelectMode(val modeName: String) : RadarAction
+ data class SstvPermissionResult(val granted: Boolean) : RadarAction
}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt
index 74772f18..80a06e4d 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.SweepGradientShader
import androidx.compose.ui.graphics.drawscope.DrawScope
@@ -220,7 +221,7 @@ private fun DrawScope.drawBodyIcon(
translate(pos.x - half, pos.y - half)
}) {
with(painter) {
- draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
+ draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
}
}
}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
index 132f9642..7898e183 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
+import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
@@ -31,10 +32,15 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
+import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
+import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
+import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
+import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -51,91 +57,100 @@ class RadarViewModel(
private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar,
- private val trackingService: IRadioTrackingService
+ private val trackingService: IRadioTrackingService,
+ private val audioCapture: IAudioCapture,
+ private val saveImage: ISaveImage,
+ private val showToast: IShowToast
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var transponders: List = emptyList()
+ private var sstvDecoder: SstvDecoder? = null
+ private var sstvRecordingJob: Job? = null
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.orientation.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
- shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
+ shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
+ sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
)
)
val uiState: StateFlow = _uiState
init {
- // Compass sensor collection
- if (settingsRepo.otherSettings.value.stateOfSensors) {
- viewModelScope.launch {
- sensorsRepo.enableSensor()
- sensorsRepo.orientation.collect { data ->
- val orientationValues = (data.first + magDeclination) to data.second
- _uiState.update { it.copy(orientationValues = orientationValues) }
- }
+ collectCompassSensor()
+ collectSettingsChanges()
+ collectPassAndStartTickLoop()
+ collectRadioTrackingState()
+ }
+
+ private fun collectCompassSensor() {
+ if (!settingsRepo.otherSettings.value.stateOfSensors) return
+ viewModelScope.launch {
+ sensorsRepo.enableSensor()
+ sensorsRepo.orientation.collect { data ->
+ val orientationValues = (data.first + magDeclination) to data.second
+ _uiState.update { it.copy(orientationValues = orientationValues) }
}
}
- // React to UTC setting changes
+ }
+
+ private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
- // Resolve which pass we're tracking and start the tick loop
+ }
+
+ private fun collectPassAndStartTickLoop() {
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value
- val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
- satPass?.let { pass ->
- _uiState.update { it.copy(currentPass = pass) }
- val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
- // Load transponders for radio control (ones that have downlink)
- transponders = transmittersList.filter { it.downlinkLow != null }
- _uiState.update { state ->
- state.copy(radioControl = state.radioControl.copy(transponders = transponders))
- }
- // Auto-expand the first item so users discover the expandable UI
- if (transmittersList.isNotEmpty()) {
- val firstUuid = transmittersList.first().uuid
- _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
- transponders.find { it.uuid == firstUuid }?.let {
- trackingService.setTransponder(it)
- }
- }
- // Compute track once (it doesn't change for a given pass)
- if (!pass.isDeepSpace) {
- val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
- _uiState.update { it.copy(satTrack = track) }
- }
- // Tick loop β position, timer, and radio updates every second
- while (isActive) {
- val timeNow = System.currentTimeMillis()
- val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
- val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
- val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
- val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
- val isLos = !pass.isDeepSpace && timeNow > pass.losTime
- _uiState.update {
- it.copy(
- currentTime = time,
- isTimeAos = isAos,
- isLos = isLos,
- orbitalPos = pos,
- sunPosition = sunPos,
- moonPosition = moonPos
- )
- }
- processRadios(transmittersList, pass.orbitalObject, timeNow)
- sendPassData(pos)
- delay(1000)
- }
+ val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
+ _uiState.update { it.copy(currentPass = pass) }
+ val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
+ transponders = transmittersList.filter { it.downlinkLow != null }
+ _uiState.update { state ->
+ state.copy(radioControl = state.radioControl.copy(transponders = transponders))
+ }
+ if (transmittersList.isNotEmpty()) {
+ val firstUuid = transmittersList.first().uuid
+ _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
+ transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
+ }
+ if (!pass.isDeepSpace) {
+ val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
+ _uiState.update { it.copy(satTrack = track) }
+ }
+ while (isActive) {
+ tickPass(pass, transmittersList)
+ delay(1000)
}
}
- // Observe radio tracking service state
+ }
+
+ private suspend fun tickPass(pass: OrbitalPass, transmittersList: List) {
+ val timeNow = System.currentTimeMillis()
+ val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
+ val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
+ val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
+ val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
+ val isLos = !pass.isDeepSpace && timeNow > pass.losTime
+ _uiState.update {
+ it.copy(
+ currentTime = time, isTimeAos = isAos, isLos = isLos,
+ orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
+ )
+ }
+ processRadios(transmittersList, pass.orbitalObject, timeNow)
+ sendPassData(pos)
+ }
+
+ private fun collectRadioTrackingState() {
viewModelScope.launch {
trackingService.state.collect { svc ->
_uiState.update { state ->
@@ -210,6 +225,33 @@ class RadarViewModel(
}
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
+
+ // SSTV actions
+ is RadarAction.SstvPermissionResult -> {
+ _uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
+ if (action.granted) initSstvDecoder()
+ }
+ RadarAction.SstvStartRecording -> startSstvRecording()
+ RadarAction.SstvStopRecording -> stopSstvRecording()
+ RadarAction.SstvSaveImage -> {
+ val frame = _uiState.value.sstv.currentFrame ?: return
+ val pixels = frame.imagePixels ?: return
+ _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) }
+ viewModelScope.launch {
+ saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
+ _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
+ showToast("Image saved")
+ }
+ }
+ is RadarAction.SstvSelectMode -> {
+ sstvDecoder?.lockMode(action.modeName)
+ _uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
+ settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
+ }
+ RadarAction.SstvReset -> {
+ sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
+ _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
+ }
}
}
@@ -281,6 +323,36 @@ class RadarViewModel(
}
}
+ private fun initSstvDecoder() {
+ if (sstvDecoder == null) {
+ val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
+ sstvDecoder = decoder
+ _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
+ viewModelScope.launch {
+ decoder.frames.collect { frame ->
+ _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
+ }
+ }
+ }
+ }
+
+ private fun startSstvRecording() {
+ if (sstvRecordingJob?.isActive == true) return
+ initSstvDecoder()
+ _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
+ sstvRecordingJob = viewModelScope.launch {
+ audioCapture.audioFlow().collect { buffer ->
+ sstvDecoder?.feedSamples(buffer)
+ }
+ }
+ }
+
+ private fun stopSstvRecording() {
+ sstvRecordingJob?.cancel()
+ sstvRecordingJob = null
+ _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
+ }
+
companion object {
val CTCSS_TONES = listOf(
@@ -307,7 +379,10 @@ class RadarViewModel(
settingsRepo = container.settingsRepo,
sensorsRepo = container.provideSensorsRepo(),
addToCalendar = container.provideAddToCalendar(),
- trackingService = container.radioTrackingService
+ trackingService = container.radioTrackingService,
+ audioCapture = container.provideAudioCapture(),
+ saveImage = container.provideSaveImage(),
+ showToast = container.provideShowToast()
)
}
}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt
deleted file mode 100644
index eea7aa99..00000000
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt
+++ /dev/null
@@ -1,369 +0,0 @@
-/*
- * Look4Sat. Amateur radio satellite tracker and pass predictor.
- * Copyright (C) 2019-2026 Arty Bishop and contributors.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package com.rtbishop.look4sat.feature.radar
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ExposedDropdownMenuAnchorType
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-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.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.rtbishop.look4sat.core.domain.model.SatRadio
-import com.rtbishop.look4sat.core.presentation.CardButton
-import java.util.Locale
-
-@Composable
-fun RadioControlPage(
- radioState: RadioControlSubState,
- onAction: (RadarAction) -> Unit,
- modifier: Modifier = Modifier
-) {
- val selectedTransponder = radioState.transponders.find {
- it.uuid == radioState.selectedTransponderUuid
- }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(horizontal = 6.dp, vertical = 6.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- // Transponder selector (dropdown)
- TransponderDropdown(
- transponders = radioState.transponders,
- selectedTransponder = selectedTransponder,
- onAction = onAction
- )
-
- // Active transponder status (TX/RX lines)
- if (selectedTransponder != null) {
- ActiveTransponderStatus(
- transponder = selectedTransponder,
- txPanel = radioState.txPanel,
- rxPanel = radioState.rxPanel
- )
- }
-
- // Frequency tuner + CTCSS
- if (radioState.txBaseFrequencyHz != null) {
- FrequencyTunerCard(
- txBaseFrequencyHz = radioState.txBaseFrequencyHz,
- selectedTransponder = selectedTransponder,
- ctcssTone = radioState.ctcssTone,
- showCtcss = selectedTransponder?.uplinkMode?.uppercase() == "FM",
- onAction = onAction
- )
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- // Error message
- radioState.errorMessage?.let { msg ->
- Text(
- text = msg,
- color = MaterialTheme.colorScheme.error,
- fontSize = 13.sp,
- modifier = Modifier.padding(horizontal = 4.dp)
- )
- }
-
- // Control buttons
- ControlButtons(
- isConnected = radioState.txPanel.isConnected || radioState.rxPanel.isConnected,
- isTracking = radioState.isTracking,
- onAction = onAction
- )
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun TransponderDropdown(
- transponders: List,
- selectedTransponder: SatRadio?,
- onAction: (RadarAction) -> Unit
-) {
- var expanded by remember { mutableStateOf(false) }
-
- ExposedDropdownMenuBox(
- expanded = expanded,
- onExpandedChange = { expanded = it }
- ) {
- OutlinedTextField(
- value = selectedTransponder?.info ?: "No transponders available",
- onValueChange = {},
- readOnly = true,
- singleLine = true,
- textStyle = MaterialTheme.typography.bodyMedium,
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier
- .fillMaxWidth()
- .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
- )
- ExposedDropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false }
- ) {
- transponders.forEach { radio ->
- val invLabel = if (radio.isInverted) " INV" else ""
- val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
- DropdownMenuItem(
- text = {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = radio.info,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f)
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = modeLabel,
- fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- },
- onClick = {
- onAction(RadarAction.SelectTransponder(radio.uuid))
- expanded = false
- }
- )
- }
- }
- }
-}
-
-@Composable
-private fun ActiveTransponderStatus(
- transponder: SatRadio,
- txPanel: RadioPanelState,
- rxPanel: RadioPanelState
-) {
- ElevatedCard(modifier = Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- // TX line (uplink β on top)
- FrequencyStatusRow(
- label = "TX",
- frequency = txPanel.frequencyHz ?: transponder.uplinkLow,
- isConnected = txPanel.isConnected,
- mode = txPanel.mode
- )
- // RX line (downlink β below)
- FrequencyStatusRow(
- label = "RX",
- frequency = rxPanel.frequencyHz ?: transponder.downlinkLow,
- isConnected = rxPanel.isConnected,
- mode = rxPanel.mode
- )
- }
- }
-}
-
-@Composable
-private fun FrequencyStatusRow(
- label: String,
- frequency: Long?,
- isConnected: Boolean,
- mode: String?
-) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = "$label:",
- fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(28.dp)
- )
- Text(
- text = frequency?.let { RadarViewModel.formatFrequency(it) } ?: "β",
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.weight(1f)
- )
- if (mode != null) {
- Text(
- text = mode,
- fontSize = 12.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(end = 6.dp)
- )
- }
- Box(
- modifier = Modifier
- .size(8.dp)
- .clip(CircleShape)
- .background(if (isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
- )
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun FrequencyTunerCard(
- txBaseFrequencyHz: Long,
- selectedTransponder: SatRadio?,
- ctcssTone: Double?,
- showCtcss: Boolean,
- onAction: (RadarAction) -> Unit
-) {
- ElevatedCard(modifier = Modifier.fillMaxWidth()) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
- ) {
- // TX Base Frequency label + value on one line
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = "TX Base: ",
- fontSize = 14.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- text = "${RadarViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
- }
- // Show band range hint if it's a wide transponder
- if (selectedTransponder != null) {
- val upLow = selectedTransponder.uplinkLow
- val upHigh = selectedTransponder.uplinkHigh
- if (upLow != null && upHigh != null && upLow != upHigh) {
- Text(
- text = "(${RadarViewModel.formatFrequency(upLow)} β ${RadarViewModel.formatFrequency(upHigh)})",
- fontSize = 11.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- Spacer(modifier = Modifier.height(4.dp))
- // Adjustment buttons
- Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- FREQ_ADJUSTMENTS.forEach { (delta, label) ->
- CardButton(
- onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
- text = label,
- modifier = Modifier.weight(1f)
- )
- }
- }
- // CTCSS inline (only for FM)
- if (showCtcss) {
- Spacer(modifier = Modifier.height(6.dp))
- Text(
- text = "CTCSS",
- fontSize = 12.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- FilterChip(
- selected = ctcssTone == null,
- onClick = { onAction(RadarAction.SetCtcssTone(null)) },
- label = { Text("Off", fontSize = 12.sp) }
- )
- RadarViewModel.CTCSS_TONES.forEach { tone ->
- FilterChip(
- selected = ctcssTone == tone,
- onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
- label = { Text(String.format(Locale.ENGLISH, "%.1f", tone), fontSize = 12.sp) }
- )
- }
- }
- }
- }
- }
-}
-
-
-@Composable
-private fun ControlButtons(
- isConnected: Boolean,
- isTracking: Boolean,
- onAction: (RadarAction) -> Unit
-) {
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- if (!isConnected) {
- CardButton(
- onClick = { onAction(RadarAction.ConnectRadios) },
- text = "Connect",
- modifier = Modifier.weight(1f)
- )
- } else {
- CardButton(
- onClick = { onAction(RadarAction.DisconnectRadios) },
- text = "Disconnect",
- modifier = Modifier.weight(1f)
- )
- }
- CardButton(
- onClick = { onAction(RadarAction.ToggleTracking) },
- text = if (isTracking) "Stop" else "Track",
- modifier = Modifier.weight(1f)
- )
- }
-}
-
-private val FREQ_ADJUSTMENTS =
- listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt
new file mode 100644
index 00000000..61c1eb9d
--- /dev/null
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt
@@ -0,0 +1,240 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.feature.radar
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.rtbishop.look4sat.core.presentation.IconCard
+import com.rtbishop.look4sat.core.presentation.R
+import com.rtbishop.look4sat.core.presentation.infiniteMarquee
+
+@Composable
+internal fun SstvPage(
+ sstv: SstvSubState,
+ onAction: (RadarAction) -> Unit,
+ requestMicPermission: () -> Unit
+) {
+ if (!sstv.hasPermission) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(text = "ποΈ", fontSize = 48.sp)
+ Text(
+ text = "Microphone access needed",
+ fontSize = 16.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Button(onClick = requestMicPermission) {
+ Text("Grant permission")
+ }
+ }
+ }
+ return
+ }
+
+ // Mode selection dialog
+ val showModeDialog = remember { mutableStateOf(false) }
+
+ if (showModeDialog.value) {
+ val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
+ Dialog(onDismissRequest = { showModeDialog.value = false }) {
+ ElevatedCard {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(vertical = 16.dp)
+ ) {
+ Text(
+ text = "SSTV Mode",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxHeight(0.5f)
+ .background(MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.spacedBy(1.dp)
+ ) {
+ items(allModes) { mode ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable {
+ onAction(RadarAction.SstvSelectMode(mode))
+ showModeDialog.value = false
+ }
+ ) {
+ Text(
+ text = mode,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp)
+ )
+ RadioButton(
+ selected = mode == sstv.selectedMode,
+ onClick = null,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Main SSTV view β image fills entire area, controls overlay at bottom
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ // Decoded image or placeholder β centered in available space
+ val frame = sstv.currentFrame
+ val pixels = frame?.imagePixels
+ if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
+ val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
+ Bitmap.createBitmap(
+ pixels,
+ frame.imageWidth,
+ frame.imageHeight,
+ Bitmap.Config.ARGB_8888
+ ).asImageBitmap()
+ }
+ Image(
+ bitmap = bitmap,
+ contentDescription = "Decoded SSTV image",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxSize()
+ .align(Alignment.Center)
+ )
+ } else {
+ Text(
+ text = if (sstv.status == SstvStatus.Recording) "Listeningβ¦" else "No signal",
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 16.sp,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ // Bottom control bar β dark, blends with the black canvas
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ ) {
+ // Mode button β opens dialog
+ ElevatedCard(
+ modifier = Modifier.weight(1f).height(48.dp),
+ onClick = { showModeDialog.value = true },
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ text = "Mode: ${sstv.selectedMode}",
+ fontSize = 14.sp,
+ maxLines = 1,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.infiniteMarquee()
+ )
+ }
+ }
+ // Reset button β clears decoder state and image
+ IconCard(
+ action = { onAction(RadarAction.SstvReset) },
+ resId = R.drawable.ic_delete
+ )
+ // Save button β always visible, enabled when there are pixels
+ IconCard(
+ action = { onAction(RadarAction.SstvSaveImage) },
+ resId = R.drawable.ic_save,
+ enabled = sstv.currentFrame?.imagePixels != null
+ )
+ // Record / Stop button
+ val playAction = {
+ when (sstv.status) {
+ SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
+ SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
+ SstvStatus.Saving -> {}
+ }
+ }
+ val playColors = CardDefaults.elevatedCardColors(
+ containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surface
+ )
+ val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
+ ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Icon(painter = painterResource(playIcon), contentDescription = null)
+ }
+ }
+ }
+ }
+}